Epic. Invoices refactoring (#351)

* filters for invoices refactoring (#320)

* refunds refactoring

* fixes and expanded-id-manager.ts improvements

* row width fix

* invoices filters

* fixes

* more fixes

* Expanded id manager custom fragment (#319)

* more more fixes

* more more more fixes

* invoices filters

* fix after conflict resolving

* more fixes

* Update build image to build:917afcdd0c0a07bf4155d597bbba72e962e1a34a

* try 4.0.3

* 4.2.0

* revert to 4.3.0

* fix

* structure improvement

* fixes

* more fixes

* prettier x2

* fix

* new constant and alias for it

* prettier x2

* fixes

* fixes for Rinat

Co-authored-by: Ildar Galeev <KeinAsylum@gmail.com>
Co-authored-by: Grigory Antsiferov <azr@bakka.su>
Co-authored-by: Rinat Arsaev <krickray@gmail.com>

* invoices refactoring panels (#338)

* table to panels (no details yet)

* undelete create invoice button

* fixes

* invoices refactoring main details (#339)

* table to panels (no details yet)

* undelete create invoice button

* invoice details and cart

* fixes

* fixes

* replay subject 1

* search limit to 10

* fix

* invoices refactoring actions (#346)

* actions

* improvements

* fixes

* fixes

* untilDestroyed

* fixes for kisalisa

* payments for invoice details (#348)

* actions

* improvements

* payments for invoice details

* fixes

* fixes

* untilDestroyed

* fixes

* paginator service

* fixes for kisalisa

* fixes

Co-authored-by: Ildar Galeev <KeinAsylum@gmail.com>
Co-authored-by: Grigory Antsiferov <azr@bakka.su>
Co-authored-by: Rinat Arsaev <krickray@gmail.com>
This commit is contained in:
Denis Ezhov 2020-12-18 17:42:08 +03:00 committed by GitHub
parent 08852ca70e
commit e01798ba19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 1717 additions and 598 deletions

View File

@ -1,8 +1,8 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Invoice, InvoiceParams, Reason } from '@dsh/api-codegen/capi';
import { InvoicesService } from '@dsh/api-codegen/capi/invoices.service';
import { Invoice, InvoiceParams } from '@dsh/api-codegen/capi/swagger-codegen';
import { Replace } from '../../../type-utils';
import { genXRequestID } from '../utils';
@ -29,4 +29,12 @@ export class InvoiceService {
getInvoicePaymentMethods(invoiceID: string) {
return this.invoicesService.getInvoicePaymentMethods(genXRequestID(), invoiceID);
}
fulfillInvoice(invoiceID: string, reason: Reason) {
return this.invoicesService.fulfillInvoice(genXRequestID(), invoiceID, reason);
}
rescindInvoice(invoiceID: string, reason: Reason) {
return this.invoicesService.rescindInvoice(genXRequestID(), invoiceID, reason);
}
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CaptureParams, PaymentSearchResult, PaymentsService } from '@dsh/api-codegen/capi/swagger-codegen';
import { CaptureParams, Payment, PaymentsService } from '@dsh/api-codegen/capi/swagger-codegen';
import { genXRequestID } from '../utils';
@ -17,7 +17,11 @@ export class PaymentService {
return this.paymentsService.capturePayment(genXRequestID(), invoiceID, paymentID, params);
}
getPaymentByID(invoiceID: string, paymentID: string): Observable<PaymentSearchResult> {
getPaymentByID(invoiceID: string, paymentID: string): Observable<Payment> {
return this.paymentsService.getPaymentByID(genXRequestID(), invoiceID, paymentID);
}
getPayments(invoiceID: string): Observable<Payment[]> {
return this.paymentsService.getPayments(genXRequestID(), invoiceID);
}
}

View File

@ -2,5 +2,5 @@ import { InjectionToken } from '@angular/core';
export const LAYOUT_GAP = new InjectionToken<string>('layoutGap');
export const DEFAULT_SEARCH_LIMIT = 20;
export const DEFAULT_SEARCH_LIMIT = 10;
export const SEARCH_LIMIT = new InjectionToken('searchLimit');

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Params } from '@angular/router';
import pickBy from 'lodash.pickby';
import { QueryParamsStore } from '@dsh/app/shared/services';
@ -11,10 +11,6 @@ const shopTypeToArray = (v, k) => typeof v === 'string' && k === 'shopIDs';
@Injectable()
export class AnalyticsSearchFiltersStore extends QueryParamsStore<SearchParams> {
constructor(protected route: ActivatedRoute, protected router: Router) {
super(router, route);
}
mapToData(queryParams: Params): Partial<SearchParams> {
return {
...queryParams,

View File

@ -1,12 +1,4 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import isEqual from 'lodash.isequal';
import { combineLatest, Observable, ReplaySubject, Subject } from 'rxjs';
import { distinctUntilChanged, map, scan, shareReplay, switchMap, take } from 'rxjs/operators';
@ -15,10 +7,10 @@ import { Shop } from '@dsh/api-codegen/capi';
import { ApiShopsService } from '@dsh/api/shop';
import { Daterange } from '@dsh/pipes/daterange';
import { ComponentChanges } from '../../../../../type-utils';
import { daterangeFromStr, strToDaterange } from '../../../../shared/utils';
import { filterShopsByRealm, removeEmptyProperties } from '../../operations/operators';
import { searchFilterParamsToDaterange } from '../../reports/reports-search-filters/search-filter-params-to-daterange';
import { SearchParams } from '../search-params';
import { daterangeToSearchParams } from './daterange-to-search-params';
import { getDefaultDaterange } from './get-default-daterange';
import { shopsToCurrencies } from './shops-to-currencies';
@ -84,19 +76,9 @@ export class AnalyticsSearchFiltersComponent implements OnChanges {
});
}
ngOnChanges({ initParams }: SimpleChanges) {
ngOnChanges({ initParams }: ComponentChanges<AnalyticsSearchFiltersComponent>) {
if (initParams && initParams.firstChange && initParams.currentValue) {
const v = initParams.currentValue;
this.daterange = !(v.fromTime || v.toTime) ? getDefaultDaterange() : searchFilterParamsToDaterange(v);
this.daterangeSelectionChange(this.daterange);
if (v.currency) {
this.selectedCurrency$.next(v.currency);
} else {
this.currencies$.pipe(take(1)).subscribe((currencies) => this.selectedCurrency$.next(currencies[0]));
}
if (v.shopIDs) {
this.selectedShopIDs$.next(v.shopIDs);
}
this.initSearchParams(initParams.currentValue);
}
}
@ -105,7 +87,7 @@ export class AnalyticsSearchFiltersComponent implements OnChanges {
if (v === null) {
this.daterange = daterange;
}
this.searchParams$.next(daterangeToSearchParams(daterange));
this.searchParams$.next(daterangeFromStr(daterange));
}
shopSelectionChange(shops: Shop[]) {
@ -116,4 +98,17 @@ export class AnalyticsSearchFiltersComponent implements OnChanges {
currencySelectionChange(currency: string) {
this.selectedCurrency$.next(currency);
}
initSearchParams({ fromTime, toTime, shopIDs, currency }: SearchParams) {
this.daterange = fromTime && toTime ? strToDaterange({ fromTime, toTime }) : getDefaultDaterange();
this.daterangeSelectionChange(this.daterange);
if (currency) {
this.selectedCurrency$.next(currency);
} else {
this.currencies$.pipe(take(1)).subscribe((currencies) => this.selectedCurrency$.next(currencies[0]));
}
if (shopIDs) {
this.selectedShopIDs$.next(shopIDs);
}
}
}

View File

@ -1,8 +0,0 @@
import { Daterange } from '@dsh/pipes/daterange';
import { SearchParams } from '../search-params';
export const daterangeToSearchParams = ({ begin, end }: Daterange): Partial<SearchParams> => ({
fromTime: begin.utc().format(),
toTime: end.utc().format(),
});

View File

@ -1,10 +0,0 @@
import moment from 'moment';
import { Daterange } from '@dsh/pipes/daterange';
import { SearchParams } from '../search-params';
export const searchParamsToDaterange = ({ fromTime, toTime }: SearchParams): Daterange => ({
begin: moment(fromTime),
end: moment(toTime),
});

View File

@ -3,9 +3,9 @@ import { ActivatedRoute } from '@angular/router';
import { Observable, ReplaySubject } from 'rxjs';
import { pluck, shareReplay, take } from 'rxjs/operators';
import { Shop } from '@dsh/api-codegen/capi/swagger-codegen';
import { SpinnerType } from '@dsh/components/indicators';
import { PaymentInstitutionRealm } from '../../../api/model';
import { AnalyticsSearchFiltersStore } from './analytics-search-filters-store.service';
import { SearchParams } from './search-params';
@ -20,7 +20,7 @@ export class AnalyticsComponent {
initSearchParams$ = this.analyticsSearchFiltersStore.data$.pipe(take(1));
realm$: Observable<Shop[]> = this.route.params.pipe(pluck('realm'), shareReplay(1));
realm$: Observable<PaymentInstitutionRealm> = this.route.params.pipe(pluck('realm'), shareReplay(1));
constructor(private analyticsSearchFiltersStore: AnalyticsSearchFiltersStore, private route: ActivatedRoute) {}

View File

@ -1 +0,0 @@
export * from './create-invoice-dialog.component';

View File

@ -1,6 +1,6 @@
<dsh-create-invoice
*transloco="let t"
[shops]="data?.shops$ | async"
[shops]="shops"
(next)="create($event)"
(back)="cancel()"
[backButton]="t('cancel')"

View File

@ -1,10 +1,10 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { Invoice, Shop } from '@dsh/api-codegen/capi';
import { CreateInvoiceDialogResponse } from '../../types/create-invoice-dialog-response';
@Component({
selector: 'dsh-create-invoice-dialog',
templateUrl: 'create-invoice-dialog.component.html',
@ -12,17 +12,15 @@ import { Invoice, Shop } from '@dsh/api-codegen/capi';
})
export class CreateInvoiceDialogComponent {
constructor(
private dialogRef: MatDialogRef<CreateInvoiceDialogComponent, 'cancel' | 'create'>,
@Inject(MAT_DIALOG_DATA) public data: { shops$: Observable<Shop[]> },
private router: Router
private dialogRef: MatDialogRef<CreateInvoiceDialogComponent, CreateInvoiceDialogResponse>,
@Inject(MAT_DIALOG_DATA) public shops: Shop[]
) {}
cancel() {
this.dialogRef.close('cancel');
}
create({ id }: Invoice) {
this.dialogRef.close('create');
this.router.navigate(['invoice', id]);
create(invoice: Invoice) {
this.dialogRef.close(invoice);
}
}

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { CreateInvoiceModule as FormCreateInvoiceModule } from '../../../../create-invoice';
import { CreateInvoiceDialogComponent } from './components/create-invoice-dialog/create-invoice-dialog.component';
import { CreateInvoiceService } from './create-invoice.service';
@NgModule({
imports: [CommonModule, TranslocoModule, FormCreateInvoiceModule],
declarations: [CreateInvoiceDialogComponent],
providers: [CreateInvoiceService],
})
export class CreateInvoiceModule {}

View File

@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslocoService } from '@ngneat/transloco';
import { Observable, of, ReplaySubject } from 'rxjs';
import { filter, pluck, switchMap, take } from 'rxjs/operators';
import { Shop } from '@dsh/api-codegen/capi';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { ApiShopsService } from '@dsh/api/shop';
import { filterShopsByRealm } from '../../operators';
import { CreateInvoiceDialogComponent } from './components/create-invoice-dialog/create-invoice-dialog.component';
@Injectable()
export class CreateInvoiceService {
constructor(
private apiShopsService: ApiShopsService,
private dialog: MatDialog,
private transloco: TranslocoService,
private snackBar: MatSnackBar
) {}
createInvoice(realm: PaymentInstitutionRealm): Observable<string> {
const invoiceCreated$ = new ReplaySubject<string>(1);
of(realm)
.pipe(
filterShopsByRealm(this.apiShopsService.shops$),
switchMap((shops) =>
this.dialog
.open<CreateInvoiceDialogComponent, Shop[]>(CreateInvoiceDialogComponent, {
width: '720px',
maxHeight: '90vh',
disableClose: true,
data: shops,
})
.afterClosed()
),
take(1),
filter((res) => res !== 'cancel'),
pluck('id')
)
.subscribe((id) => {
invoiceCreated$.next(id);
this.snackBar.open(
this.transloco.translate('invoices.actions.invoiceCreated', null, 'operations'),
'OK',
{
duration: 2000,
}
);
});
return invoiceCreated$;
}
}

View File

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

View File

@ -0,0 +1,3 @@
import { Invoice } from '@dsh/api-codegen/capi';
export type CreateInvoiceDialogResponse = Invoice | 'cancel';

View File

@ -0,0 +1,13 @@
<dsh-row
*transloco="let t; scope: 'operations'; read: 'operations.invoices.headerRow'"
fxLayout="row"
fxLayoutAlign="space-between center"
fxLayoutGap="24px"
color="primary"
>
<dsh-row-header-label fxFlex="40">{{ t('productName') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="10">{{ t('amount') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="10">{{ t('status') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="20">{{ t('createdAt') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="20" fxHide.lt-md> {{ t('shop') }} </dsh-row-header-label>
</dsh-row>

View File

@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'dsh-invoice-row-header',
templateUrl: 'invoice-row-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceRowHeaderComponent {}

View File

@ -0,0 +1,15 @@
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-label fxFlex="40">
{{ invoice.product }}
</dsh-row-label>
<dsh-row-label fxFlex="10">
<span class="dsh-body-2">{{ invoice.amount | toMajor | currency: invoice.currency }}</span>
</dsh-row-label>
<dsh-row-label fxFlex="10">
<dsh-status [color]="invoice.status | invoiceStatusColor">{{ invoice.status | invoiceStatusName }}</dsh-status>
</dsh-row-label>
<dsh-row-label fxFlex="20">{{ invoice.createdAt | date: 'dd MMMM yyyy, HH:mm' }}</dsh-row-label>
<dsh-row-label fxFlex="20" fxHide.lt-md>
{{ invoice.shopID | shopDetails }}
</dsh-row-label>
</dsh-row>

View File

@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Invoice } from '../../../../../../../api-codegen/anapi';
@Component({
selector: 'dsh-invoice-row',
templateUrl: 'invoice-row.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceRowComponent {
@Input() invoice: Invoice;
}

View File

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

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslocoModule } from '@ngneat/transloco';
import { ButtonModule } from '@dsh/components/buttons';
import { CreateInvoiceModule as FormCreateInvoiceModule } from '../../../../../../create-invoice';
import { CreatePaymentLinkModule as ApiCreatePaymentLinkModule } from '../../../../../../create-payment-link';
import { CancelInvoiceDialogComponent } from './components/cancel-invoice-dialog/cancel-invoice-dialog.component';
@NgModule({
imports: [
CommonModule,
TranslocoModule,
FormCreateInvoiceModule,
ApiCreatePaymentLinkModule,
FlexModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatDialogModule,
ButtonModule,
],
declarations: [CancelInvoiceDialogComponent],
})
export class CancelInvoiceModule {}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslocoService } from '@ngneat/transloco';
import { Observable, ReplaySubject } from 'rxjs';
import { filter, switchMap, take } from 'rxjs/operators';
import { InvoiceService } from '@dsh/api/invoice';
import { CancelInvoiceDialogComponent } from './components/cancel-invoice-dialog/cancel-invoice-dialog.component';
@Injectable()
export class CancelInvoiceService {
constructor(
private invoiceService: InvoiceService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private transloco: TranslocoService
) {}
cancelInvoice(invoiceID: string): Observable<void> {
const invoiceCancelled$ = new ReplaySubject<void>(1);
this.dialog
.open(CancelInvoiceDialogComponent, {
width: '720px',
maxHeight: '90vh',
disableClose: true,
})
.afterClosed()
.pipe(
take(1),
filter((value) => value !== 'cancel'),
switchMap((reason) => this.invoiceService.rescindInvoice(invoiceID, reason))
)
.subscribe(() => {
invoiceCancelled$.next();
this.snackBar.open(
this.transloco.translate('invoices.actions.invoiceCancelled', null, 'operations'),
'OK'
);
});
return invoiceCancelled$;
}
}

View File

@ -0,0 +1,15 @@
<div fxLayout="column" fxLayoutGap="32px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.actions'">
<div class="dsh-headline">{{ t('cancelInvoice') }}</div>
<mat-dialog-content>
<mat-form-field fxFlex>
<mat-label>{{ t('reason') }}</mat-label>
<input [formControl]="reason" required matInput type="text" autocomplete="off" />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions fxLayout="row" fxLayoutAlign="space-between center" *transloco="let c">
<button fxFlex="126px" dsh-stroked-button color="warn" (click)="cancel()">{{ c('cancel') }}</button>
<button class="action-button" dsh-button color="accent" [disabled]="!reason.valid" (click)="accept()">
{{ c('confirm') }}
</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { FormControl } from '@ngneat/reactive-forms';
import { CancelInvoiceDialogResponse } from '../../types/cancel-invoice-dialog-response';
@Component({
templateUrl: 'cancel-invoice-dialog.component.html',
styleUrls: ['cancel-invoice-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CancelInvoiceDialogComponent {
constructor(private dialogRef: MatDialogRef<CancelInvoiceDialogComponent, CancelInvoiceDialogResponse>) {}
reason = new FormControl<string>();
cancel() {
this.dialogRef.close('cancel');
}
accept() {
this.dialogRef.close({ reason: this.reason.value });
}
}

View File

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

View File

@ -0,0 +1,3 @@
import { Reason } from '@dsh/api-codegen/capi';
export type CancelInvoiceDialogResponse = Reason | 'cancel';

View File

@ -0,0 +1,14 @@
<div fxLayout="column" fxLayoutGap="24px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.actions'">
<div class="mat-title">
{{ t('title') }}
</div>
<div fxLayout fxLayoutGap="24px">
<ng-container *ngIf="invoice.status === 'unpaid'">
<button dsh-button color="accent" (click)="createPaymentLink()">{{ t('createPaymentLink') }}</button>
<button dsh-button color="warn" (click)="cancelInvoice()">{{ t('cancelInvoice') }}</button>
</ng-container>
<ng-container *ngIf="invoice.status === 'paid'">
<button dsh-button color="warn" (click)="fulfillInvoice()">{{ t('fulfillInvoice') }}</button>
</ng-container>
</div>
</div>

View File

@ -0,0 +1,44 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Invoice } from '../../../../../../../../api-codegen/anapi';
import { CancelInvoiceService } from '../../cancel-invoice';
import { CreatePaymentLinkService } from '../../create-payment-link';
import { FulfillInvoiceService } from '../../fulfill-invoice';
@UntilDestroy()
@Component({
selector: 'dsh-invoice-actions',
templateUrl: 'invoice-actions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CancelInvoiceService, FulfillInvoiceService],
})
export class InvoiceActionsComponent {
@Input() invoice: Invoice;
@Output() refreshData = new EventEmitter<void>();
constructor(
private createPaymentLinkService: CreatePaymentLinkService,
private fulfillInvoiceService: FulfillInvoiceService,
private cancelInvoiceService: CancelInvoiceService
) {}
createPaymentLink() {
this.createPaymentLinkService.createPaymentLink({ invoice: this.invoice });
}
cancelInvoice() {
this.cancelInvoiceService
.cancelInvoice(this.invoice.id)
.pipe(untilDestroyed(this))
.subscribe(() => this.refreshData.emit());
}
fulfillInvoice() {
this.fulfillInvoiceService
.fulfillInvoice(this.invoice.id)
.pipe(untilDestroyed(this))
.subscribe(() => this.refreshData.emit());
}
}

View File

@ -0,0 +1,19 @@
<div fxLayout="column" fxLayoutGap="24px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.cartInfo'">
<dsh-details-item [title]="t('description')" fxFlex>
{{ line.product }}
</dsh-details-item>
<div fxLayout fxLayout.xs="column" fxLayoutGap="24px">
<dsh-details-item [title]="t('price')" fxFlex>
{{ line.price | toMajor | currency: currency }}
</dsh-details-item>
<dsh-details-item [title]="t('quantity')" fxFlex>
{{ line.quantity }}
</dsh-details-item>
<dsh-details-item *ngIf="line.taxMode; else emptyBlock" [title]="t('tax')" fxFlex>
{{ line.taxMode | taxModeToTaxRate }}
</dsh-details-item>
<ng-template #emptyBlock>
<div fxFlex></div>
</ng-template>
</div>
</div>

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { InvoiceLine } from '../../../../../../../../../api-codegen/anapi';
import { ReceiveInvoiceService } from '../../../services/receive-invoice/receive-invoice.service';
@Component({
selector: 'dsh-invoice-cart-line',
templateUrl: 'invoice-cart-line.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ReceiveInvoiceService],
})
export class InvoiceCartLineComponent {
@Input() line: InvoiceLine;
@Input() currency: string;
}

View File

@ -0,0 +1,9 @@
<div fxLayout="column" fxLayoutGap="24px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.cartInfo'">
<div class="mat-title">
{{ t('title') }}
</div>
<ng-container *ngFor="let line of cart; index as i">
<dsh-invoice-cart-line [line]="line" [currency]="currency"></dsh-invoice-cart-line>
<mat-divider *ngIf="i < cart.length - 1"></mat-divider>
</ng-container>
</div>

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { InvoiceCart } from '../../../../../../../../api-codegen/anapi';
import { ReceiveInvoiceService } from '../../services/receive-invoice/receive-invoice.service';
@Component({
selector: 'dsh-invoice-cart-info',
templateUrl: 'invoice-cart-info.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ReceiveInvoiceService],
})
export class InvoiceCartInfoComponent {
@Input() cart: InvoiceCart;
@Input() currency: string;
}

View File

@ -0,0 +1,6 @@
<div fxLayout="column" fxLayoutGap="24px" *transloco="let t; scope: 'operations'; read: 'operations.invoices'">
<dsh-invoice-details [invoice]="invoice"></dsh-invoice-details>
<dsh-details-item [title]="t('shop')">
{{ invoice.shopID | shopDetails }}
</dsh-details-item>
</div>

View File

@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Invoice } from '../../../../../../../../api-codegen/anapi';
@Component({
selector: 'dsh-invoice-main-info',
templateUrl: 'invoice-main-info.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceMainInfoComponent {
@Input() invoice: Invoice;
}

View File

@ -0,0 +1,13 @@
<div fxLayout="column" fxLayoutGap="24px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.payments'">
<div class="mat-title">
{{ t('title') }}
</div>
<div *ngFor="let payment of payments$ | async as payments; index as i" fxLayout="column" fxLayoutGap="24px">
<div class="dsh-subheading-2">{{ t('payment') }} #{{ payment.id }}</div>
<dsh-payment-details [payment]="payment"></dsh-payment-details>
<mat-divider *ngIf="i < payments.length - 1"></mat-divider>
</div>
<div *ngIf="hasMore$ | async">
<button dsh-button fxFlex (click)="showMore()">{{ t('showMore') }}</button>
</div>
</div>

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Payment } from '@dsh/api-codegen/capi';
import { FakePaginatorService } from '@dsh/app/shared/services';
@Component({
selector: 'dsh-invoice-payments',
templateUrl: 'invoice-payments.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [FakePaginatorService],
})
export class InvoicePaymentsComponent implements OnInit {
@Input() payments: Payment[];
payments$ = this.paginationService.values$;
hasMore$ = this.paginationService.hasMore$;
constructor(private paginationService: FakePaginatorService<Payment>) {}
ngOnInit() {
this.paginationService.init(this.payments);
}
showMore() {
this.paginationService.showMore();
}
}

View File

@ -0,0 +1 @@
<dsh-create-payment-link cancelButton (cancel)="cancel()" [invoice]="data.invoice"></dsh-create-payment-link>

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Invoice } from '../../../../../../../../../api-codegen/capi';
@Component({
selector: 'dsh-create-payment-link-dialog',
templateUrl: 'create-payment-link-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreatePaymentLinkDialogComponent {
constructor(
private dialogRef: MatDialogRef<CreatePaymentLinkDialogComponent, 'cancel'>,
@Inject(MAT_DIALOG_DATA) public data: { invoice: Invoice }
) {}
cancel() {
this.dialogRef.close('cancel');
}
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { CreateInvoiceModule as FormCreateInvoiceModule } from '../../../../../../create-invoice';
import { CreatePaymentLinkModule as ApiCreatePaymentLinkModule } from '../../../../../../create-payment-link';
import { CreatePaymentLinkDialogComponent } from './components/create-payment-link-dialog/create-payment-link-dialog.component';
import { CreatePaymentLinkService } from './create-payment-link.service';
@NgModule({
imports: [CommonModule, TranslocoModule, FormCreateInvoiceModule, ApiCreatePaymentLinkModule],
declarations: [CreatePaymentLinkDialogComponent],
providers: [CreatePaymentLinkService],
})
export class CreatePaymentLinkModule {}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { CreatePaymentLinkDialogComponent } from './components/create-payment-link-dialog/create-payment-link-dialog.component';
import { CreatePaymentLinkDialogConfig } from './types/create-payment-link-dialog-config';
@Injectable()
export class CreatePaymentLinkService {
constructor(private dialog: MatDialog) {}
createPaymentLink(config: CreatePaymentLinkDialogConfig): void {
this.dialog.open<CreatePaymentLinkDialogComponent, CreatePaymentLinkDialogConfig>(
CreatePaymentLinkDialogComponent,
{
width: '720px',
maxHeight: '90vh',
disableClose: true,
data: config,
}
);
}
}

View File

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

View File

@ -0,0 +1,5 @@
import { Invoice } from '../../../../../../../../api-codegen/capi';
export interface CreatePaymentLinkDialogConfig {
invoice: Invoice;
}

View File

@ -0,0 +1 @@
export type CreatePaymentLinkDialogResponse = 'create' | 'cancel';

View File

@ -0,0 +1,15 @@
<div fxLayout="column" fxLayoutGap="32px" *transloco="let t; scope: 'operations'; read: 'operations.invoices.actions'">
<div class="dsh-headline">{{ t('fulfillInvoice') }}</div>
<mat-dialog-content>
<mat-form-field fxFlex>
<mat-label>{{ t('reason') }}</mat-label>
<input [formControl]="reason" required matInput type="text" autocomplete="off" />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions fxLayout="row" fxLayoutAlign="space-between center" *transloco="let c">
<button fxFlex="126px" dsh-stroked-button color="warn" (click)="cancel()">{{ c('cancel') }}</button>
<button class="action-button" dsh-button color="accent" [disabled]="!reason.valid" (click)="accept()">
{{ c('confirm') }}
</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { FormControl } from '@ngneat/reactive-forms';
import { FulfillInvoiceDialogResponse } from '../../types/fulfill-invoice-dialog-response';
@Component({
templateUrl: 'fulfill-invoice-dialog.component.html',
styleUrls: ['fulfill-invoice-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FulfillInvoiceDialogComponent {
constructor(private dialogRef: MatDialogRef<FulfillInvoiceDialogComponent, FulfillInvoiceDialogResponse>) {}
reason = new FormControl<string>();
cancel() {
this.dialogRef.close('cancel');
}
accept() {
this.dialogRef.close({ reason: this.reason.value });
}
}

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslocoModule } from '@ngneat/transloco';
import { ButtonModule } from '@dsh/components/buttons';
import { CreateInvoiceModule as FormCreateInvoiceModule } from '../../../../../../create-invoice';
import { CreatePaymentLinkModule as ApiCreatePaymentLinkModule } from '../../../../../../create-payment-link';
import { FulfillInvoiceDialogComponent } from './components/cancel-invoice-dialog/fulfill-invoice-dialog.component';
@NgModule({
imports: [
CommonModule,
TranslocoModule,
FormCreateInvoiceModule,
ApiCreatePaymentLinkModule,
FlexModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatDialogModule,
ButtonModule,
],
declarations: [FulfillInvoiceDialogComponent],
})
export class FulfillInvoiceModule {}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslocoService } from '@ngneat/transloco';
import { Observable, ReplaySubject } from 'rxjs';
import { filter, switchMap, take } from 'rxjs/operators';
import { InvoiceService } from '@dsh/api/invoice';
import { FulfillInvoiceDialogComponent } from './components/cancel-invoice-dialog/fulfill-invoice-dialog.component';
@Injectable()
export class FulfillInvoiceService {
constructor(
private invoiceService: InvoiceService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private transloco: TranslocoService
) {}
fulfillInvoice(invoiceID: string): Observable<void> {
const invoiceFulfilled$ = new ReplaySubject<void>(1);
this.dialog
.open(FulfillInvoiceDialogComponent, {
width: '720px',
maxHeight: '90vh',
disableClose: true,
})
.afterClosed()
.pipe(
take(1),
filter((value) => value !== 'cancel'),
switchMap((reason) => this.invoiceService.fulfillInvoice(invoiceID, reason))
)
.subscribe(() => {
invoiceFulfilled$.next();
this.snackBar.open(
this.transloco.translate('invoices.actions.invoiceFulfilled', null, 'operations'),
'OK'
);
});
return invoiceFulfilled$;
}
}

View File

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

View File

@ -0,0 +1,3 @@
import { Reason } from '@dsh/api-codegen/capi';
export type FulfillInvoiceDialogResponse = Reason | 'cancel';

View File

@ -0,0 +1,6 @@
import { Reason } from '@dsh/api-codegen/capi';
export interface FulfillInvoiceParams {
invoiceID: string;
reason: Reason;
}

View File

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

View File

@ -0,0 +1,15 @@
<div fxLayout="column" fxLayoutGap="24px">
<dsh-invoice-main-info [invoice]="invoice"></dsh-invoice-main-info>
<ng-container *ngIf="isCartAvailable(invoice.cart)">
<mat-divider></mat-divider>
<dsh-invoice-cart-info [cart]="invoice.cart" [currency]="invoice.currency"></dsh-invoice-cart-info>
</ng-container>
<ng-container *ngIf="isActionsAvailable(invoice?.status)">
<mat-divider></mat-divider>
<dsh-invoice-actions [invoice]="invoice" (refreshData)="refreshData.emit()"></dsh-invoice-actions>
</ng-container>
<ng-container *ngIf="(payments$ | async)?.length">
<mat-divider></mat-divider>
<dsh-invoice-payments [payments]="payments$ | async"></dsh-invoice-payments>
</ng-container>
</div>

View File

@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Invoice, InvoiceCart } from '@dsh/api-codegen/anapi';
import { ReceivePaymentsService } from './services/receive-payments/receive-payments.service';
@Component({
selector: 'dsh-invoice-invoice-details',
templateUrl: 'invoice-details.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ReceivePaymentsService],
})
export class InvoiceDetailsComponent implements OnInit {
@Input() invoice: Invoice;
@Output() refreshData = new EventEmitter<void>();
payments$ = this.receivePaymentsService.payments$;
constructor(private receivePaymentsService: ReceivePaymentsService) {}
ngOnInit() {
this.receivePaymentsService.receivePayments(this.invoice.id);
}
isActionsAvailable(status: Invoice.StatusEnum): boolean {
return ['paid', 'unpaid'].includes(status);
}
isCartAvailable(cart: InvoiceCart): boolean {
return Boolean(cart?.length);
}
}

View File

@ -0,0 +1,62 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslocoModule } from '@ngneat/transloco';
import { PaymentModule } from '@dsh/api/payment';
import {
InvoiceDetailsModule as InvoiceInvoiceDetailsModule,
PaymentDetailsModule,
RefundDetailsModule as ApiRefundDetailsModule,
} from '@dsh/app/shared/components';
import { ApiModelRefsModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../../../../to-major';
import { CancelInvoiceModule } from './cancel-invoice';
import { InvoiceActionsComponent } from './components/invoice-actions/invoice-actions.component';
import { InvoiceCartLineComponent } from './components/invoice-cart-info/cart-info/invoice-cart-line.component';
import { InvoiceCartInfoComponent } from './components/invoice-cart-info/invoice-cart-info.component';
import { InvoiceMainInfoComponent } from './components/invoice-main-info/invoice-main-info.component';
import { InvoicePaymentsComponent } from './components/invoice-payments/invoice-payments.component';
import { CreatePaymentLinkModule } from './create-payment-link';
import { FulfillInvoiceModule } from './fulfill-invoice';
import { InvoiceDetailsComponent } from './invoice-details.component';
import { TaxModeToTaxRatePipe } from './pipes/tax-mode-to-tax-rate/tax-mode-to-tax-rate.pipe';
@NgModule({
imports: [
TranslocoModule,
LayoutModule,
ButtonModule,
FlexLayoutModule,
CommonModule,
MatSnackBarModule,
MatDividerModule,
IndicatorsModule,
ApiModelRefsModule,
ApiRefundDetailsModule,
PaymentDetailsModule,
PaymentModule,
InvoiceInvoiceDetailsModule,
ToMajorModule,
CreatePaymentLinkModule,
CancelInvoiceModule,
FulfillInvoiceModule,
],
declarations: [
InvoiceDetailsComponent,
InvoiceMainInfoComponent,
InvoiceCartInfoComponent,
InvoiceCartLineComponent,
TaxModeToTaxRatePipe,
InvoiceActionsComponent,
InvoicePaymentsComponent,
],
exports: [InvoiceDetailsComponent],
})
export class InvoiceDetailsModule {}

View File

@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';
import { InvoiceLineTaxMode, InvoiceLineTaxVAT } from '../../../../../../../../api-codegen/anapi';
@Pipe({
name: 'taxModeToTaxRate',
})
export class TaxModeToTaxRatePipe implements PipeTransform {
transform(taxMode: InvoiceLineTaxMode): string {
switch (taxMode.type) {
case InvoiceLineTaxMode.TypeEnum.InvoiceLineTaxVAT:
return (taxMode as InvoiceLineTaxVAT).rate;
}
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { catchError, filter, map, switchMap, tap } from 'rxjs/operators';
import { Invoice } from '@dsh/api-codegen/capi';
import { InvoiceService } from '@dsh/api/invoice';
@UntilDestroy()
@Injectable()
export class ReceiveInvoiceService {
isLoading$: Observable<boolean>;
errorOccurred$: Observable<boolean>;
invoice$: Observable<Invoice>;
private receiveInvoice$ = new Subject<string>();
private loading$ = new BehaviorSubject(false);
private error$ = new Subject<boolean>();
private receivedInvoice$ = new ReplaySubject<Invoice>(1);
constructor(private invoiceService: InvoiceService) {
this.isLoading$ = this.loading$.asObservable();
this.errorOccurred$ = this.error$.asObservable();
this.invoice$ = this.receivedInvoice$.asObservable();
this.receiveInvoice$
.pipe(
tap(() => this.loading$.next(true)),
switchMap((invoiceID: string) =>
this.invoiceService.getInvoiceByID(invoiceID).pipe(
catchError((e) => {
console.error(e);
this.loading$.next(false);
this.error$.next();
return of('error');
})
)
),
filter((result) => result !== 'error'),
map((r) => r as Invoice),
untilDestroyed(this)
)
.subscribe((invoice: Invoice) => {
this.loading$.next(false);
this.receivedInvoice$.next(invoice);
});
}
receivePayment(invoiceID: string) {
this.receiveInvoice$.next(invoiceID);
}
}

View File

@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import moment from 'moment';
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { catchError, filter, map, switchMap, tap } from 'rxjs/operators';
import { Payment } from '@dsh/api-codegen/capi';
import { PaymentService } from '@dsh/api/payment';
@UntilDestroy()
@Injectable()
export class ReceivePaymentsService {
isLoading$: Observable<boolean>;
errorOccurred$: Observable<boolean>;
payments$: Observable<Payment[]>;
private receivePayments$ = new Subject<string>();
private loading$ = new BehaviorSubject(false);
private error$ = new Subject<boolean>();
private receivedPayments$ = new ReplaySubject<Payment[]>(1);
constructor(private paymentService: PaymentService) {
this.isLoading$ = this.loading$.asObservable();
this.errorOccurred$ = this.error$.asObservable();
this.payments$ = this.receivedPayments$.asObservable();
this.receivePayments$
.pipe(
tap(() => this.loading$.next(true)),
switchMap((invoiceID) =>
this.paymentService.getPayments(invoiceID).pipe(
catchError((e) => {
console.error(e);
this.loading$.next(false);
this.error$.next();
return of('error');
})
)
),
filter((result) => result !== 'error'),
map((r) => r as Payment[]),
map((payments) =>
payments.sort((a, b) => moment(b.createdAt).valueOf() - moment(a.createdAt).valueOf())
),
untilDestroyed(this)
)
.subscribe((payments: Payment[]) => {
this.loading$.next(false);
this.receivedPayments$.next(payments);
});
}
receivePayments(invoiceID: string) {
this.receivePayments$.next(invoiceID);
}
}

View File

@ -0,0 +1,27 @@
<div fxLayout="column" fxLayoutGap="16px">
<dsh-last-updated [lastUpdated]="lastUpdated" (update)="refreshData.emit()"></dsh-last-updated>
<dsh-invoice-row-header></dsh-invoice-row-header>
<dsh-accordion
fxLayout="column"
fxLayoutGap="16px"
(expandedChange)="expandedIdChange.emit($event)"
[expanded]="expandedId"
>
<dsh-accordion-item *ngFor="let invoice of invoices" #accordionItem>
<dsh-invoice-row [invoice]="invoice"></dsh-invoice-row>
<ng-template dshLazyPanelContent>
<dsh-card fxFlexFill fxLayout="column" fxLayoutGap="32px">
<dsh-accordion-item-content-header
*transloco="let t; scope: 'operations'; read: 'operations.invoices'"
(collapse)="accordionItem.collapse($event)"
>{{ t('header') }} #{{ invoice.id }}</dsh-accordion-item-content-header
>
<dsh-invoice-invoice-details
[invoice]="invoice"
(refreshData)="refreshData.emit()"
></dsh-invoice-invoice-details>
</dsh-card>
</ng-template>
</dsh-accordion-item>
</dsh-accordion>
</div>

View File

@ -0,0 +1,15 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Invoice } from '../../../../../api-codegen/anapi';
@Component({
selector: 'dsh-invoices-list',
templateUrl: 'invoices-list.component.html',
})
export class InvoicesListComponent {
@Input() invoices: Invoice[];
@Input() expandedId: number;
@Input() lastUpdated: string;
@Output() expandedIdChange = new EventEmitter<number>();
@Output() refreshData = new EventEmitter<void>();
}

View File

@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslocoModule } from '@ngneat/transloco';
import { InvoiceModule } from '@dsh/api/invoice';
import { InvoiceDetailsModule as ApiInvoiceDetailsModule } from '@dsh/app/shared/components';
import { ApiModelRefsModule } from '@dsh/app/shared/pipes';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../../../to-major';
import { InvoiceRowHeaderComponent } from './components/invoice-row-header/invoice-row-header.component';
import { InvoiceRowComponent } from './components/invoice-row/invoice-row.component';
import { InvoiceDetailsModule } from './invoice-details';
import { InvoicesListComponent } from './invoices-list.component';
@NgModule({
imports: [
TranslocoModule,
MatSnackBarModule,
LayoutModule,
FlexLayoutModule,
CommonModule,
IndicatorsModule,
ToMajorModule,
ApiModelRefsModule,
InvoiceModule,
InvoiceDetailsModule,
InvoiceDetailsModule,
ApiInvoiceDetailsModule,
],
declarations: [InvoicesListComponent, InvoiceRowHeaderComponent, InvoiceRowComponent],
exports: [InvoicesListComponent],
})
export class InvoicesListModule {}

View File

@ -0,0 +1,8 @@
import moment from 'moment';
import { Daterange } from '@dsh/pipes/daterange';
export const getDefaultDaterange = (): Daterange => ({
begin: moment().startOf('M'),
end: moment().endOf('M'),
});

View File

@ -0,0 +1,3 @@
export * from './invoices-search-filters.module';
export * from './invoices-search-filters.component';
export * from './search-filters-params';

View File

@ -0,0 +1,19 @@
<div fxLayout="row" fxLayoutGap="16px">
<dsh-daterange-filter
[selected]="daterange"
(selectedChange)="daterangeSelectionChange($event)"
></dsh-daterange-filter>
<dsh-invoices-filter
[selected]="initParams?.invoiceIDs"
(selectedChange)="invoiceSelectionChange($event)"
></dsh-invoices-filter>
<dsh-filter-shops
[shops]="shops$ | async"
[selected]="selectedShops$ | async"
(selectedChange)="shopSelectionChange($event)"
></dsh-filter-shops>
<dsh-invoice-status-filter
[selected]="initParams?.invoiceStatus"
(selectedChange)="statusSelectionChange($event)"
></dsh-invoice-status-filter>
</div>

View File

@ -0,0 +1,103 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import isEqual from 'lodash.isequal';
import isNil from 'lodash.isnil';
import { Observable, ReplaySubject, Subject } from 'rxjs';
import { distinctUntilChanged, map, scan, shareReplay, switchMap, take } from 'rxjs/operators';
import { Daterange } from '@dsh/pipes/daterange';
import { ComponentChanges } from '../../../../../../type-utils';
import { Invoice } from '../../../../../api-codegen/anapi/swagger-codegen';
import { Shop } from '../../../../../api-codegen/capi/swagger-codegen';
import { PaymentInstitutionRealm } from '../../../../../api/model';
import { ApiShopsService } from '../../../../../api/shop';
import { SHARE_REPLAY_CONF } from '../../../../../custom-operators';
import { daterangeFromStr, strToDaterange } from '../../../../../shared/utils';
import { filterShopsByRealm } from '../../operators';
import { getDefaultDaterange } from './get-default-daterange';
import { SearchFiltersParams } from './search-filters-params';
@Component({
selector: 'dsh-invoices-search-filters',
templateUrl: 'invoices-search-filters.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoicesSearchFiltersComponent implements OnChanges, OnInit {
@Input() initParams: SearchFiltersParams;
@Input() set realm(realm: PaymentInstitutionRealm) {
this.realm$.next(realm);
}
@Output() searchParamsChanges: EventEmitter<SearchFiltersParams> = new EventEmitter();
daterange: Daterange;
shops$: Observable<Shop[]>;
selectedShops$: Observable<Shop[]>;
private realm$ = new ReplaySubject<PaymentInstitutionRealm>();
private selectedShopIDs$ = new ReplaySubject<string[]>(1);
private searchParams$: Subject<Partial<SearchFiltersParams>> = new ReplaySubject(1);
constructor(private shopService: ApiShopsService) {
this.shops$ = this.realm$.pipe(filterShopsByRealm(this.shopService.shops$), shareReplay(SHARE_REPLAY_CONF));
this.selectedShops$ = this.selectedShopIDs$.pipe(
switchMap((ids) =>
this.shops$.pipe(
take(1),
map((shops) => shops.filter((shop) => ids.includes(shop.id)))
)
),
shareReplay(1)
);
}
ngOnInit() {
this.selectedShopIDs$
.pipe(map((ids) => (ids.length ? ids : null)))
.subscribe((shopIDs) => this.searchParams$.next({ shopIDs }));
this.searchParams$
.pipe(
distinctUntilChanged(isEqual),
scan((acc, current) => ({ ...acc, ...current }), this.initParams)
)
.subscribe((v) => this.searchParamsChanges.emit(v));
}
ngOnChanges({ initParams }: ComponentChanges<InvoicesSearchFiltersComponent>) {
if (initParams && initParams.firstChange && initParams.currentValue) {
this.initSearchParams(initParams.currentValue);
}
}
daterangeSelectionChange(range: Daterange | null) {
const daterange = isNil(range) ? getDefaultDaterange() : range;
if (isNil(range)) {
this.daterange = daterange;
}
this.searchParams$.next(daterangeFromStr(daterange));
}
shopSelectionChange(shops: Shop[]) {
const shopIDs = shops.map((shop) => shop.id);
this.selectedShopIDs$.next(shopIDs);
}
invoiceSelectionChange(invoiceIDs: string[]) {
this.searchParams$.next({ invoiceIDs: invoiceIDs?.length ? invoiceIDs : null });
}
statusSelectionChange(invoiceStatus: Invoice.StatusEnum) {
this.searchParams$.next({ invoiceStatus });
}
private initSearchParams({ fromTime, toTime, shopIDs }: SearchFiltersParams) {
this.daterange = fromTime && toTime ? strToDaterange({ fromTime, toTime }) : getDefaultDaterange();
this.daterangeSelectionChange(this.daterange);
if (shopIDs) {
this.selectedShopIDs$.next(shopIDs);
}
}
}

View File

@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import {
FilterShopsModule,
InvoicesFilterModule,
InvoiceStatusFilterModule,
RefundStatusFilterModule,
} from '@dsh/app/shared/components';
import { DaterangeFilterModule } from '@dsh/components/filters/daterange-filter';
import { InvoicesSearchFiltersComponent } from './invoices-search-filters.component';
@NgModule({
imports: [
CommonModule,
TranslocoModule,
FlexLayoutModule,
InvoicesFilterModule,
FilterShopsModule,
RefundStatusFilterModule,
InvoiceStatusFilterModule,
DaterangeFilterModule,
],
declarations: [InvoicesSearchFiltersComponent],
exports: [InvoicesSearchFiltersComponent],
})
export class InvoicesSearchFiltersModule {}

View File

@ -0,0 +1,9 @@
import { Invoice } from '../../../../../api-codegen/anapi/swagger-codegen';
export interface SearchFiltersParams {
fromTime: string;
toTime: string;
invoiceIDs: string[];
shopIDs?: string[];
invoiceStatus?: Invoice.StatusEnum;
}

View File

@ -1,23 +1,31 @@
<div fxLayout="column" fxLayoutGap="20px" *transloco="let i; scope: 'invoices'; read: 'invoices'">
<div fxLayout="column" fxLayoutAlign=" end">
<button dsh-stroked-button color="accent" (click)="create()">{{ i('create.button') }}</button>
</div>
<dsh-search-form (formValueChanges)="search($event)"></dsh-search-form>
<dsh-card>
<dsh-card-content *transloco="let t" fxLayout="column" fxLayoutGap="15px">
<dsh-last-updated
*ngIf="!(isLoading$ | async)"
(update)="refresh()"
[lastUpdated]="lastUpdated$ | async"
></dsh-last-updated>
<dsh-spinner *ngIf="isLoading$ | async" [type]="spinnerType" size="20"></dsh-spinner>
<dsh-invoices-table [data]="tableData$ | async"></dsh-invoices-table>
<dsh-empty-search-result
*ngIf="!(tableData$ | async)?.length && !(doAction$ | async)"
></dsh-empty-search-result>
<button *ngIf="hasMoreInvoices$ | async" (click)="fetchMore()" dsh-button>
{{ (isLoading$ | async) ? t('loading') : t('showMore') }}
<div fxLayout="column" fxLayoutGap="32px">
<div fxLayout fxLayoutAlign="space-between" fxLayoutGap="24px">
<dsh-invoices-search-filters
[initParams]="initSearchParams$ | async"
[realm]="realm$ | async"
(searchParamsChanges)="searchParamsChanges($event)"
></dsh-invoices-search-filters>
<button *transloco="let i; scope: 'invoices'; read: 'invoices'" dsh-button color="accent" (click)="create()">
{{ i('create.button') }}
</button>
</dsh-card-content>
</dsh-card>
</div>
<div fxLayout="column" fxLayoutGap="16px">
<dsh-invoices-list
[expandedId]="expandedId$ | async"
(expandedIdChange)="expandedIdChange($event)"
[invoices]="invoices$ | async"
[lastUpdated]="lastUpdated$ | async"
(refreshData)="refresh()"
></dsh-invoices-list>
<dsh-show-more-panel
*ngIf="hasMore$ | async"
[isLoading]="isLoading$ | async"
(showMore)="fetchMore()"
></dsh-show-more-panel>
</div>
<dsh-empty-search-result
*ngIf="!(fetchErrors$ | async) && (invoices$ | async)?.length === 0"
></dsh-empty-search-result>
<dsh-spinner *ngIf="isLoading$ | async" fxLayoutAlign="center"></dsh-spinner>
</div>
<dsh-scroll-up></dsh-scroll-up>

View File

@ -1,40 +1,61 @@
import { Component } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslocoService } from '@ngneat/transloco';
import { shareReplay } from 'rxjs/operators';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Observable } from 'rxjs';
import { pluck, shareReplay, take } from 'rxjs/operators';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { SpinnerType } from '@dsh/components/indicators';
import { booleanDebounceTime, SHARE_REPLAY_CONF } from '../../../../custom-operators';
import { CreateInvoiceDialogComponent } from './create-invoice-dialog';
import { InvoicesService } from './invoices.service';
import { InvoiceSearchFormValue } from './search-form';
import { CreateInvoiceService } from './create-invoice';
import { SearchFiltersParams } from './invoices-search-filters';
import { FetchInvoicesService } from './services/fetch-invoices/fetch-invoices.service';
import { InvoicesExpandedIdManager } from './services/invoices-expanded-id-manager/invoices-expanded-id-manager.service';
import { InvoicesSearchFiltersStore } from './services/invoices-search-filters-store/invoices-search-filters-store.service';
@UntilDestroy()
@Component({
selector: 'dsh-invoices',
templateUrl: 'invoices.component.html',
providers: [InvoicesService],
providers: [FetchInvoicesService, InvoicesSearchFiltersStore, InvoicesExpandedIdManager],
})
export class InvoicesComponent {
tableData$ = this.invoicesService.invoicesTableData$;
hasMoreInvoices$ = this.invoicesService.hasMore$;
export class InvoicesComponent implements OnInit {
invoices$ = this.invoicesService.searchResult$;
hasMore$ = this.invoicesService.hasMore$;
lastUpdated$ = this.invoicesService.lastUpdated$;
doAction$ = this.invoicesService.doAction$;
isLoading$ = this.doAction$.pipe(booleanDebounceTime(), shareReplay(SHARE_REPLAY_CONF));
isLoading$ = this.invoicesService.isLoading$;
expandedId$ = this.invoicesExpandedIdManager.expandedId$;
initSearchParams$ = this.invoicesSearchFiltersStore.data$.pipe(take(1));
fetchErrors$ = this.invoicesService.errors$;
spinnerType = SpinnerType.FulfillingBouncingCircle;
realm$: Observable<PaymentInstitutionRealm> = this.route.params.pipe(pluck('realm'), shareReplay(1));
constructor(
private invoicesService: InvoicesService,
private invoicesService: FetchInvoicesService,
private createInvoiceService: CreateInvoiceService,
private invoicesSearchFiltersStore: InvoicesSearchFiltersStore,
private invoicesExpandedIdManager: InvoicesExpandedIdManager,
private snackBar: MatSnackBar,
private transloco: TranslocoService,
private dialog: MatDialog
) {
this.invoicesService.errors$.subscribe(() => this.snackBar.open(this.transloco.translate('commonError'), 'OK'));
private route: ActivatedRoute
) {}
ngOnInit() {
this.invoicesService.errors$
.pipe(untilDestroyed(this))
.subscribe(() => this.snackBar.open(this.transloco.translate('commonError'), 'OK'));
}
search(val: InvoiceSearchFormValue) {
this.invoicesService.search(val);
searchParamsChanges(p: SearchFiltersParams) {
this.invoicesService.search(p);
this.invoicesSearchFiltersStore.preserve(p);
}
expandedIdChange(id: number) {
this.invoicesExpandedIdManager.expandedIdChange(id);
}
fetchMore() {
@ -46,13 +67,19 @@ export class InvoicesComponent {
}
create() {
this.dialog.open(CreateInvoiceDialogComponent, {
width: '720px',
maxHeight: '90vh',
disableClose: true,
data: {
shops$: this.invoicesService.shops$,
},
});
this.route.params.pipe(pluck('realm'), take(1)).subscribe((realm: PaymentInstitutionRealm) =>
this.createInvoiceService
.createInvoice(realm)
.pipe(untilDestroyed(this))
.subscribe((invoiceID) => this.refreshAndShowNewInvoice(invoiceID))
);
}
refreshAndShowNewInvoice(invoiceID: string) {
this.refresh();
// TODO: open created invoice panel
// this.invoices$.pipe(take(1), map(invoices => invoices.findIndex((invoice) => invoice.id === invoiceID))).subscribe((id) => {
// this.expandedIdChange(id)
// });
}
}

View File

@ -14,24 +14,24 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslocoModule, TRANSLOCO_SCOPE } from '@ngneat/transloco';
import { InvoiceModule } from '@dsh/api/invoice';
import { InvoiceDetailsModule } from '@dsh/app/shared/components';
import { ButtonModule } from '@dsh/components/buttons';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { FormControlsModule, RangeDatepickerModule } from '@dsh/components/form-controls';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { StateNavModule } from '@dsh/components/navigation';
import { ScrollUpModule, StateNavModule } from '@dsh/components/navigation';
import { ShowMorePanelModule } from '@dsh/components/show-more-panel';
import { TableModule } from '@dsh/components/table';
import { LanguageModule } from '../../../../language';
import { ToMajorModule } from '../../../../to-major';
import { CreateInvoiceModule } from '../../../create-invoice';
import { ShopSelectorModule } from '../../../shop-selector';
import { CreateInvoiceDialogComponent } from './create-invoice-dialog';
import { CreateInvoiceModule } from './create-invoice';
import { InvoicesListModule } from './invoices-list';
import { InvoicesRoutingModule } from './invoices-routing.module';
import { InvoicesSearchFiltersModule } from './invoices-search-filters';
import { InvoicesComponent } from './invoices.component';
import { SearchFormComponent } from './search-form';
import { InvoiceStatusColorPipe } from './status-color.pipe';
import { TableComponent } from './table';
@NgModule({
imports: [
@ -62,14 +62,13 @@ import { TableComponent } from './table';
MatDividerModule,
ShopSelectorModule,
CreateInvoiceModule,
InvoicesSearchFiltersModule,
InvoiceDetailsModule,
InvoicesListModule,
ScrollUpModule,
ShowMorePanelModule,
],
declarations: [
InvoicesComponent,
SearchFormComponent,
InvoiceStatusColorPipe,
TableComponent,
CreateInvoiceDialogComponent,
],
declarations: [InvoicesComponent],
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'main' }],
})
export class InvoicesModule {}

View File

@ -1,70 +0,0 @@
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslocoService } from '@ngneat/transloco';
import { combineLatest, Observable } from 'rxjs';
import { catchError, pluck, shareReplay, switchMap } from 'rxjs/operators';
import { Invoice } from '@dsh/api-codegen/anapi';
import { Shop } from '@dsh/api-codegen/capi';
import { InvoiceSearchService } from '@dsh/api/search';
import { ApiShopsService } from '@dsh/api/shop';
import { SHARE_REPLAY_CONF } from '../../../../custom-operators';
import { FetchResult, PartialFetcher } from '../../../partial-fetcher';
import { filterShopsByRealm, mapToTimestamp } from '../operators';
import { mapToInvoicesTableData } from './map-to-invoices-table-data';
import { InvoiceSearchFormValue } from './search-form';
import { InvoicesTableData } from './table';
@Injectable()
export class InvoicesService extends PartialFetcher<Invoice, InvoiceSearchFormValue> {
private readonly searchLimit = 20;
lastUpdated$: Observable<string> = this.searchResult$.pipe(mapToTimestamp);
invoicesTableData$: Observable<InvoicesTableData[]> = combineLatest([
this.searchResult$,
this.shopService.shops$,
]).pipe(
mapToInvoicesTableData,
catchError(() => {
this.snackBar.open(this.transloco.translate('httpError'), 'OK');
return [];
})
);
shops$: Observable<Shop[]> = this.route.params.pipe(
pluck('realm'),
filterShopsByRealm(this.shopService.shops$),
shareReplay(SHARE_REPLAY_CONF)
);
constructor(
private route: ActivatedRoute,
private invoiceSearchService: InvoiceSearchService,
private shopService: ApiShopsService,
private snackBar: MatSnackBar,
private transloco: TranslocoService
) {
super();
}
protected fetch(params: InvoiceSearchFormValue, continuationToken: string): Observable<FetchResult<Invoice>> {
return this.route.params.pipe(
pluck('realm'),
switchMap((paymentInstitutionRealm) =>
this.invoiceSearchService.searchInvoices(
params.date.begin.utc().format(),
params.date.end.utc().format(),
{
...params,
paymentInstitutionRealm,
},
this.searchLimit,
continuationToken
)
)
);
}
}

View File

@ -1,2 +0,0 @@
export * from './invoice-search-form-value';
export * from './search-form.component';

View File

@ -1,30 +0,0 @@
import {
BankCardPaymentSystem,
BankCardTokenProvider,
Invoice,
PaymentStatus,
PaymentTerminalProvider,
} from '@dsh/api-codegen/anapi/swagger-codegen';
import { SearchFormValue } from '../../../search-form-value';
export interface InvoiceSearchFormValue extends SearchFormValue {
invoiceStatus?: Invoice.StatusEnum;
invoiceAmount?: number;
shopIDs?: string[];
paymentStatus?: PaymentStatus.StatusEnum;
paymentFlow?: 'instant' | 'hold';
paymentMethod?: 'bankCard' | 'paymentTerminal';
paymentTerminalProvider?: PaymentTerminalProvider;
invoiceID?: string;
paymentID?: string;
payerEmail?: string;
payerIP?: string;
payerFingerprint?: string;
customerID?: string;
first6?: string;
last4?: string;
bankCardTokenProvider?: BankCardTokenProvider;
bankCardPaymentSystem?: BankCardPaymentSystem;
paymentAmount?: number;
}

View File

@ -1,37 +0,0 @@
<dsh-float-panel
*transloco="let t; scope: 'operations'; read: 'operations.invoices.filter'"
[formGroup]="searchForm"
novalidate
[(expanded)]="expanded"
>
<ng-container *transloco="let c">
<dsh-justify-wrapper fxLayout fxLayout.lt-md="column" [fxLayoutGap]="layoutGap">
<dsh-range-datepicker formControlName="date" fxFlex></dsh-range-datepicker>
<dsh-shop-selector formControlName="shopIDs" fxFlex></dsh-shop-selector>
<mat-form-field fxFlex>
<mat-label>{{ t('invoiceStatus') }}</mat-label>
<mat-select formControlName="invoiceStatus">
<mat-option>
{{ c('any') }}
</mat-option>
<mat-option *ngFor="let status of statuses" [value]="status">
<span *transloco="let invoiceStatus; read: 'invoiceStatus'">{{ invoiceStatus(status) }}</span>
</mat-option>
</mat-select>
</mat-form-field>
</dsh-justify-wrapper>
<dsh-float-panel-actions>
<button dsh-button (click)="reset()">
{{ c('resetSearchParams') }}
</button>
</dsh-float-panel-actions>
<dsh-float-panel-more>
<div gdAuto gdGap="0 {{ layoutGap }}" gdColumns="1fr 1fr 1fr" gdColumns.lt-md="1fr">
<mat-form-field>
<mat-label>{{ t('invoiceID') }}</mat-label>
<input matInput formControlName="invoiceID" />
</mat-form-field>
</div>
</dsh-float-panel-more>
</ng-container>
</dsh-float-panel>

View File

@ -1,36 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { debounceTime } from 'rxjs/operators';
import { Invoice } from '@dsh/api-codegen/anapi/swagger-codegen';
import { InvoiceSearchFormValue } from './invoice-search-form-value';
import { SearchFormService } from './search-form.service';
@Component({
selector: 'dsh-search-form',
templateUrl: 'search-form.component.html',
providers: [SearchFormService],
})
export class SearchFormComponent implements OnInit {
@Input() valueDebounceTime = 300;
@Input() layoutGap = '20px';
@Output() formValueChanges: EventEmitter<InvoiceSearchFormValue> = new EventEmitter<InvoiceSearchFormValue>();
searchForm: FormGroup = this.searchFormService.searchForm;
expanded = false;
statuses: Invoice.StatusEnum[] = Object.values(Invoice.StatusEnum);
constructor(private searchFormService: SearchFormService) {}
ngOnInit() {
this.searchFormService.formValueChanges$
.pipe(debounceTime(this.valueDebounceTime))
.subscribe((v) => this.formValueChanges.emit(v));
}
reset() {
this.searchFormService.reset();
}
}

View File

@ -1,58 +0,0 @@
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import isEmpty from 'lodash.isempty';
import * as moment from 'moment';
import { Observable } from 'rxjs';
import { filter, shareReplay, startWith, take } from 'rxjs/operators';
import { removeEmptyProperties } from '../../operators';
import { toFormValue } from '../../to-form-value';
import { toQueryParams } from '../../to-query-params';
import { InvoiceSearchFormValue } from './invoice-search-form-value';
@Injectable()
export class SearchFormService {
searchForm: FormGroup = this.initForm();
private defaultValues: InvoiceSearchFormValue = this.searchForm.value;
formValueChanges$: Observable<InvoiceSearchFormValue> = this.searchForm.valueChanges.pipe(
startWith(this.defaultValues),
filter(() => this.searchForm.status === 'VALID'),
removeEmptyProperties,
shareReplay(1)
);
constructor(private fb: FormBuilder, private router: Router, private route: ActivatedRoute) {
this.formValueChanges$.subscribe((formValues) =>
this.router.navigate([location.pathname], { queryParams: toQueryParams(formValues) })
);
this.defaultValues = this.searchForm.value;
this.init();
}
reset() {
this.searchForm.reset(this.defaultValues);
}
private init() {
this.route.queryParams
.pipe(
take(1),
filter((p) => !isEmpty(p))
)
.subscribe((p) => this.searchForm.patchValue(toFormValue<InvoiceSearchFormValue>(p)));
}
private initForm(defaultLimit = 20): FormGroup {
return this.fb.group({
date: {
begin: moment().startOf('month'),
end: moment().endOf('month'),
},
limit: [defaultLimit, Validators.required],
invoiceStatus: '',
shopIDs: [],
invoiceID: '',
});
}
}

View File

@ -0,0 +1,45 @@
import { Inject, Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { pluck, shareReplay, switchMap } from 'rxjs/operators';
import { InvoiceSearchService } from '../../../../../../api';
import { Invoice } from '../../../../../../api-codegen/anapi';
import { booleanDebounceTime } from '../../../../../../custom-operators';
import { SEARCH_LIMIT } from '../../../../../constants';
import { FetchResult, PartialFetcher } from '../../../../../partial-fetcher';
import { mapToTimestamp } from '../../../operators';
import { SearchFiltersParams } from '../../invoices-search-filters';
@Injectable()
export class FetchInvoicesService extends PartialFetcher<Invoice, SearchFiltersParams> {
isLoading$: Observable<boolean> = this.doAction$.pipe(booleanDebounceTime(), shareReplay(1));
lastUpdated$: Observable<string> = this.searchResult$.pipe(mapToTimestamp);
constructor(
private route: ActivatedRoute,
private invoiceSearchService: InvoiceSearchService,
@Inject(SEARCH_LIMIT)
private searchLimit: number
) {
super();
}
protected fetch(params: SearchFiltersParams, continuationToken: string): Observable<FetchResult<Invoice>> {
return this.route.params.pipe(
pluck('realm'),
switchMap((paymentInstitutionRealm) =>
this.invoiceSearchService.searchInvoices(
params.fromTime,
params.toTime,
{
...params,
paymentInstitutionRealm,
},
this.searchLimit,
continuationToken
)
)
);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ExpandedIdManager } from '@dsh/app/shared/services';
import { Invoice } from '../../../../../../api-codegen/anapi';
import { FetchInvoicesService } from '../fetch-invoices/fetch-invoices.service';
@Injectable()
export class InvoicesExpandedIdManager extends ExpandedIdManager<Invoice> {
constructor(
protected route: ActivatedRoute,
protected router: Router,
private fetchInvoicesService: FetchInvoicesService
) {
super(route, router);
}
protected fragmentNotFound(): void {
this.fetchInvoicesService.fetchMore();
}
protected get dataSet$(): Observable<Invoice[]> {
return this.fetchInvoicesService.searchResult$;
}
}

View File

@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import pickBy from 'lodash.pickby';
import { QueryParamsStore } from '@dsh/app/shared/services';
import { wrapValuesToArray } from '../../../../../../../utils';
import { SearchFiltersParams } from '../../invoices-search-filters';
@Injectable()
export class InvoicesSearchFiltersStore extends QueryParamsStore<SearchFiltersParams> {
mapToData(queryParams: Params): Partial<SearchFiltersParams> {
return {
...queryParams,
...wrapValuesToArray(this.pickShopsAndInvoices(queryParams)),
};
}
mapToParams(data: SearchFiltersParams): Params {
return data;
}
pickShopsAndInvoices(params: any) {
return pickBy(params, (v, k) => typeof v === 'string' && ['shopIDs', 'invoiceIDs'].includes(k));
}
}

View File

@ -1,23 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core';
import { Invoice } from '@dsh/api-codegen/anapi/swagger-codegen';
import { StatusColor } from '../../../../theme-manager';
@Pipe({
name: 'invoiceStatusColor',
})
export class InvoiceStatusColorPipe implements PipeTransform {
transform(status: Invoice.StatusEnum): StatusColor {
const statusEnum = Invoice.StatusEnum;
switch (status) {
case statusEnum.Paid:
case statusEnum.Fulfilled:
return StatusColor.success;
case statusEnum.Cancelled:
return StatusColor.warn;
case statusEnum.Unpaid:
return StatusColor.pending;
}
}
}

View File

@ -1,2 +0,0 @@
export * from './invoices-table-data';
export * from './table.component';

View File

@ -1,11 +0,0 @@
import { InvoiceStatus } from '@dsh/api-codegen/anapi';
export interface InvoicesTableData {
amount: number;
currency: string;
status: InvoiceStatus.StatusEnum;
createdAt: string;
invoiceID: string;
shopName: string;
product: string;
}

View File

@ -1,76 +0,0 @@
<div *transloco="let t; scope: 'operations'; read: 'operations.invoices.table'" class="table-container">
<table dshTable [dataSource]="data">
<ng-container dshColumnDef="invoiceID">
<th dshHeaderCell fxFlex="130px" *dshHeaderCellDef>
{{ t('invoiceID') }}
</th>
<td dshCell fxFlex="130px" *dshCellDef="let element">
{{ element.invoiceID }}
</td>
</ng-container>
<ng-container dshColumnDef="product">
<th dshHeaderCell fxFlex *dshHeaderCellDef>
{{ t('product') }}
</th>
<td dshCell *dshCellDef="let element">
<span class="truncate">{{ element.product }}</span>
</td>
</ng-container>
<ng-container dshColumnDef="amount">
<th dshHeaderCell *dshHeaderCellDef>
{{ t('amount') }}
</th>
<td dshCell *dshCellDef="let element">
{{ element.amount | toMajor | currency: element.currency }}
</td>
</ng-container>
<ng-container dshColumnDef="status">
<th dshHeaderCell fxFlex="130px" *dshHeaderCellDef>
{{ t('status') }}
</th>
<td dshCell fxFlex="130px" *dshCellDef="let element">
<dsh-status
*transloco="let invoiceStatus; read: 'invoiceStatus'"
[color]="element.status | invoiceStatusColor"
>{{ invoiceStatus(element.status) }}</dsh-status
>
</td>
</ng-container>
<ng-container dshColumnDef="createdAt">
<th dshHeaderCell fxFlex="130px" *dshHeaderCellDef>
{{ t('createdAt') }}
</th>
<td dshCell fxFlex="130px" *dshCellDef="let element">
{{ element.createdAt | date: 'dd.MM.yyyy, HH:mm' }}
</td>
</ng-container>
<ng-container dshColumnDef="shop">
<th dshHeaderCell *dshHeaderCellDef>{{ t('shop') }}</th>
<td dshCell *dshCellDef="let element">
<span class="truncate">{{ element.shopName }}</span>
</td>
</ng-container>
<ng-container dshColumnDef="actions">
<th dshHeaderCell fxFlex="42px" *dshHeaderCellDef></th>
<td dshCell fxFlex="42px" *dshCellDef="let element">
<button dsh-icon-button [matMenuTriggerFor]="menu">
<mat-icon svgIcon="more_vert"></mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="goToInvoiceDetails(element)">
{{ t('actions.invoiceDetails') }}
</button>
</mat-menu>
</td>
</ng-container>
<tr dsh-header-row *dshHeaderRowDef="displayedColumns"></tr>
<tr dsh-table-row *dshRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>

View File

@ -1,13 +0,0 @@
@import '../../../../../../styles/utils/typography';
.truncate {
@include typo-truncate(100%, 289px);
}
.table-container {
overflow-x: auto;
}
.dsh-table {
width: 100%;
}

View File

@ -1,22 +0,0 @@
import { Component, Input } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { InvoicesTableData } from './invoices-table-data';
@Component({
selector: 'dsh-invoices-table',
templateUrl: 'table.component.html',
styleUrls: ['table.component.scss'],
})
export class TableComponent {
@Input() data: MatTableDataSource<InvoicesTableData>;
displayedColumns: string[] = ['invoiceID', 'product', 'amount', 'status', 'createdAt', 'shop', 'actions'];
constructor(private router: Router) {}
goToInvoiceDetails({ invoiceID }: InvoicesTableData) {
this.router.navigate(['invoice', invoiceID]);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ExpandedIdManager, Fragment } from '@dsh/app/shared/services';
import { RefundSearchResult } from '../../../../api-codegen/capi';
import { FetchRefundsService } from './services/fetch-refunds/fetch-refunds.service';
@Injectable()
export class RefundsExpandedIdManager extends ExpandedIdManager<RefundSearchResult> {
constructor(
protected route: ActivatedRoute,
protected router: Router,
private fetchRefundsService: FetchRefundsService
) {
super(route, router);
}
protected toFragment(r: RefundSearchResult): Fragment {
return `${r.invoiceID}${r.paymentID}${r.id}`;
}
protected get dataSet$(): Observable<RefundSearchResult[]> {
return this.fetchRefundsService.searchResult$;
}
}

View File

@ -1,6 +1,6 @@
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-label fxFlex>
{{ refund.amount | toMajor | currency: refund.currency }}
<span class="dsh-body-2">{{ refund.amount | toMajor | currency: refund.currency }}</span>
</dsh-row-label>
<dsh-row-label fxFlex>
<dsh-status [color]="refund.status | refundStatusColor">{{ refund.status | refundStatusName }}</dsh-status>

View File

@ -7,7 +7,7 @@
{{ t('title') }} <span dshSecondaryTitle>#{{ (invoice$ | async)?.id }}</span>
</div>
<ng-container *ngIf="isLoading$ | async; else afterLoading">
<div class="dsh-body-1">{{ t('loading') }}</div>
<div *transloco="let c" class="dsh-body-1">{{ c('loading') }}</div>
</ng-container>
<ng-template #afterLoading>
<div *ngIf="errorOccurred$ | async; else content" class="dsh-body-1">{{ t('error') }}</div>

View File

@ -5,7 +5,7 @@
>
<div class="mat-title">{{ t('title') }}</div>
<ng-container *ngIf="isLoading$ | async; else afterLoading">
<div class="dsh-body-1">{{ t('loading') }}</div>
<div *transloco="let c" class="dsh-body-1">{{ c('loading') }}</div>
</ng-container>
<ng-template #afterLoading>
<div *ngIf="errorOccurred$ | async; else content" class="dsh-body-1">{{ t('error') }}</div>

View File

@ -0,0 +1 @@
export * from './refund-row-header.component';

View File

@ -0,0 +1,12 @@
<dsh-row
*transloco="let t; scope: 'operations'; read: 'operations.refunds.headerRow'"
fxLayout="row"
fxLayoutAlign="space-between center"
fxLayoutGap="24px"
color="primary"
>
<dsh-row-header-label fxFlex>{{ t('amount') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex>{{ t('status') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex>{{ t('updatedAt') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex fxHide.lt-md> {{ t('shop') }} </dsh-row-header-label>
</dsh-row>

View File

@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'dsh-refund-row-header',
templateUrl: 'refund-row-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RefundRowHeaderComponent {}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import isString from 'lodash.isstring';
import pickBy from 'lodash.pickby';
import { QueryParamsStore } from '@dsh/app/shared/services';
import { wrapValuesToArray } from '../../../../../utils';
import { SearchFiltersParams } from './refunds-search-filters';
const shopsAndInvoicesToArray = (v: any, k: string) => isString(v) && ['shopIDs', 'invoiceIDs'].includes(k);
@Injectable()
export class RefundsSearchFiltersStore extends QueryParamsStore<SearchFiltersParams> {
constructor(protected route: ActivatedRoute, protected router: Router) {
super(router, route);
}
mapToData(queryParams: Params): Partial<SearchFiltersParams> {
return {
...queryParams,
...wrapValuesToArray(pickBy(queryParams, shopsAndInvoicesToArray)),
};
}
mapToParams(data: SearchFiltersParams): Params {
return data;
}
}

View File

@ -1,8 +0,0 @@
import { Daterange } from '@dsh/pipes/daterange';
import { SearchFiltersParams } from '../types/search-filters-params';
export const daterangeToSearchFilterParams = ({ begin, end }: Daterange): Partial<SearchFiltersParams> => ({
fromTime: begin.utc().format(),
toTime: end.utc().format(),
});

View File

@ -20,11 +20,10 @@ import { ApiShopsService } from '@dsh/api/shop';
import { Daterange } from '@dsh/pipes/daterange';
import { SHARE_REPLAY_CONF } from '../../../../../custom-operators';
import { daterangeFromStr, strToDaterange } from '../../../../../shared/utils';
import { filterShopsByRealm } from '../../operators';
import { SearchFiltersParams } from '../types/search-filters-params';
import { daterangeToSearchFilterParams } from './daterange-to-search-filter-params';
import { getDefaultDaterange } from './get-default-daterange';
import { searchFilterParamsToDaterange } from './search-filter-params-to-daterange';
@Component({
selector: 'dsh-refunds-search-filters',
@ -84,7 +83,7 @@ export class RefundsSearchFiltersComponent implements OnChanges, OnInit {
if (isNil(range)) {
this.daterange = daterange;
}
this.searchParams$.next(daterangeToSearchFilterParams(daterange));
this.searchParams$.next(daterangeFromStr(daterange));
}
shopSelectionChange(shops: Shop[]) {
@ -103,7 +102,7 @@ export class RefundsSearchFiltersComponent implements OnChanges, OnInit {
private init(initParams: SimpleChange) {
if (initParams && initParams.firstChange && initParams.currentValue) {
const v = initParams.currentValue;
this.daterange = !(v.fromTime || v.toTime) ? getDefaultDaterange() : searchFilterParamsToDaterange(v);
this.daterange = !(v.fromTime || v.toTime) ? getDefaultDaterange() : strToDaterange(v);
this.daterangeSelectionChange(this.daterange);
if (Array.isArray(v.shopIDs)) {
this.selectedShopIDs$.next(v.shopIDs);

View File

@ -1,10 +0,0 @@
import moment from 'moment';
import { Daterange } from '@dsh/pipes/daterange';
import { SearchFiltersParams } from '../types/search-filters-params';
export const searchFilterParamsToDaterange = ({ fromTime, toTime }: SearchFiltersParams): Daterange => ({
begin: moment(fromTime),
end: moment(toTime),
});

View File

@ -0,0 +1,9 @@
import { RefundSearchResult } from '../../../../../api-codegen/capi/swagger-codegen';
export interface SearchFiltersParams {
fromTime: string;
toTime: string;
invoiceIDs: string[];
shopIDs: string[];
refundStatus: RefundSearchResult.StatusEnum;
}

View File

@ -1,6 +1,5 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import isString from 'lodash.isstring';
import { Params } from '@angular/router';
import pickBy from 'lodash.pickby';
import { QueryParamsStore } from '@dsh/app/shared/services';
@ -8,22 +7,20 @@ import { QueryParamsStore } from '@dsh/app/shared/services';
import { wrapValuesToArray } from '../../../../../../../utils';
import { SearchFiltersParams } from '../../refunds-search-filters';
const shopsAndInvoicesToArray = (v: any, k: string) => isString(v) && ['shopIDs', 'invoiceIDs'].includes(k);
@Injectable()
export class RefundsSearchFiltersStore extends QueryParamsStore<SearchFiltersParams> {
constructor(protected route: ActivatedRoute, protected router: Router) {
super(router, route);
}
mapToData(queryParams: Params): Partial<SearchFiltersParams> {
return {
...queryParams,
...wrapValuesToArray(pickBy(queryParams, shopsAndInvoicesToArray)),
...wrapValuesToArray(this.pickShopsAndInvoices(queryParams)),
};
}
mapToParams(data: SearchFiltersParams): Params {
return data;
}
private pickShopsAndInvoices(params: any) {
return pickBy(params, (v, k) => typeof v === 'string' && ['shopIDs', 'invoiceIDs'].includes(k));
}
}

View File

@ -2,8 +2,8 @@ import { NgModule } from '@angular/core';
import { ShopModule } from '@dsh/api/shop';
import { WalletModule } from '@dsh/api/wallet';
import { DEFAULT_SEARCH_LIMIT, LAYOUT_GAP, SEARCH_LIMIT } from '@dsh/app/sections/constants';
import { LAYOUT_GAP } from './constants';
import { MainModule } from './main';
import { SectionsRoutingModule } from './sections-routing.module';
import { SectionsComponent } from './sections.component';
@ -12,6 +12,9 @@ import { SectionsComponent } from './sections.component';
imports: [MainModule, SectionsRoutingModule, ShopModule, WalletModule],
declarations: [SectionsComponent],
exports: [SectionsComponent],
providers: [{ provide: LAYOUT_GAP, useValue: '20px' }],
providers: [
{ provide: LAYOUT_GAP, useValue: '20px' },
{ provide: SEARCH_LIMIT, useValue: DEFAULT_SEARCH_LIMIT },
],
})
export class SectionsModule {}

Some files were not shown because too many files have changed in this diff Show More