IMP-241: Create deposits by CSV (#366)

This commit is contained in:
Rinat Arsaev 2024-06-18 16:32:20 +05:00 committed by GitHub
parent 26832f6087
commit e4f044193c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 501 additions and 281 deletions

View File

@ -20,12 +20,14 @@ import {
} from '@vality/ng-core';
import { ThriftPipesModule } from '@vality/ng-thrift';
import { MagistaThriftFormComponent } from '@cc/app/shared/components/thrift-api-crud';
import { UploadCsvComponent } from '../../../components/upload-csv';
import { PageLayoutModule, ShopFieldModule } from '../../shared';
import { MerchantFieldModule } from '../../shared/components/merchant-field';
import { ThriftFormModule } from '../../shared/components/metadata-form';
import { DomainThriftFormComponent } from '../../shared/components/thrift-api-crud/domain/domain-thrift-form';
import {
MagistaThriftFormComponent,
DomainThriftFormComponent,
} from '../../shared/components/thrift-api-crud';
import { ChargebacksRoutingModule } from './chargebacks-routing.module';
import { ChargebacksComponent } from './chargebacks.component';
@ -64,6 +66,7 @@ import { CreateChargebacksByFileDialogComponent } from './components/create-char
MatExpansionModule,
MatInputModule,
MatCheckboxModule,
UploadCsvComponent,
],
})
export class ChargebacksModule {}

View File

@ -1,36 +1,9 @@
<v-dialog [progress]="progress$ | async" title="Create chargebacks">
<div style="display: flex; flex-direction: column; gap: 16px">
<mat-checkbox [formControl]="hasHeaderControl">CSV with header</mat-checkbox>
<v-file-upload [extensions]="['csv']" (upload)="loadFile($event)"></v-file-upload>
<mat-accordion>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Default format</mat-panel-title>
</mat-expansion-panel-header>
<div class="mat-body-1">
<code style="word-wrap: break-word; word-break: break-word">{{
defaultFormat
}}</code>
</div>
<div class="mat-body-1">
Categories: fraud, dispute, authorisation, processing_error
</div>
<div class="mat-body-1">Starting with "body" optional</div>
<div class="mat-body-1">Separator dot comma (;)</div>
</mat-expansion-panel>
</mat-accordion>
<v-table
[(rowSelected)]="selectedChargebacks"
[columns]="columns"
[data]="chargebacks$ | async"
noActions
rowSelectable
></v-table>
</div>
<cc-upload-csv
[formatDescription]="['reason.category: fraud, dispute, authorisation, processing_error']"
[props]="props"
(selectedChange)="selected = $event"
></cc-upload-csv>
<v-dialog-actions>
<button
*ngIf="successfullyChargebacks?.length"
@ -41,7 +14,7 @@
Close and find {{ successfullyChargebacks.length }} successful chargebacks
</button>
<button
[disabled]="!selectedChargebacks?.length || !!(progress$ | async)"
[disabled]="!selected?.length || !!(progress$ | async)"
color="primary"
mat-button
(click)="create()"

View File

@ -1,162 +1,48 @@
import { Component, OnInit, DestroyRef } from '@angular/core';
import { Component, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { InvoicePaymentChargeback } from '@vality/domain-proto/domain';
import { InvoicePaymentChargebackParams } from '@vality/domain-proto/payment_processing';
import {
DialogSuperclass,
NotifyLogService,
loadFileContent,
DEFAULT_DIALOG_CONFIG,
forkJoinToResult,
Column,
} from '@vality/ng-core';
import { getUnionKey } from '@vality/ng-thrift';
import startCase from 'lodash-es/startCase';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { switchMap, map, shareReplay, tap, startWith } from 'rxjs/operators';
import { BehaviorSubject } from 'rxjs';
import { InvoicingService } from '@cc/app/api/payment-processing';
import { parseCsv, unifyCsvItems } from '@cc/utils';
import { AmountCurrencyService } from '../../../../shared/services';
import { CSV_CHARGEBACK_PROPS } from './types/csv-chargeback-props';
import { csvChargebacksToInvoicePaymentChargebackParams } from './utils/csv-chargebacks-to-invoice-payment-chargeback-params';
interface ChargebackParams {
invoiceId: string;
paymentId: string;
params: InvoicePaymentChargebackParams;
}
import { CSV_CHARGEBACK_PROPS, CsvChargeback } from './types/csv-chargeback';
import { getCreateChargebackArgs } from './utils/get-create-chargeback-args';
@Component({
selector: 'cc-create-chargebacks-by-file-dialog',
templateUrl: './create-chargebacks-by-file-dialog.component.html',
})
export class CreateChargebacksByFileDialogComponent
extends DialogSuperclass<
CreateChargebacksByFileDialogComponent,
void,
InvoicePaymentChargeback[]
>
implements OnInit
{
export class CreateChargebacksByFileDialogComponent extends DialogSuperclass<
CreateChargebacksByFileDialogComponent,
void,
InvoicePaymentChargeback[]
> {
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
hasHeaderControl = new FormControl(true);
progress$ = new BehaviorSubject(0);
upload$ = new BehaviorSubject<File | null>(null);
columns: Column<ChargebackParams>[] = [
{ field: 'invoiceId' },
{ field: 'paymentId' },
// { field: 'params.id' },
{
field: 'params.reason',
formatter: ({ params }) => startCase(getUnionKey(params.reason.category)),
description: ({ params }) => params.reason.code,
},
{
field: 'levy',
type: 'currency',
formatter: ({ params }) =>
this.amountCurrencyService.toMajor(
params.levy.amount,
params.levy.currency.symbolic_code,
),
typeParameters: {
currencyCode: ({ params }) => params.levy.currency.symbolic_code,
},
},
{
field: 'params.body',
type: 'currency',
formatter: ({ params }) =>
this.amountCurrencyService.toMajor(
params.body?.amount,
params.body?.currency?.symbolic_code,
),
typeParameters: {
currencyCode: ({ params }) => params.body?.currency?.symbolic_code,
},
},
{
field: 'params.transaction_info.id',
header: 'Transaction id',
},
{
field: 'params.transaction_info.timestamp',
header: 'Transaction timestamp',
type: 'datetime',
},
{
field: 'params.external_id',
},
{
field: 'params.occurred_at',
type: 'datetime',
},
];
defaultFormat = CSV_CHARGEBACK_PROPS.join(';');
selectedChargebacks: ChargebackParams[] = [];
chargebacks$ = combineLatest([
this.upload$,
this.hasHeaderControl.valueChanges.pipe(startWith(null)),
]).pipe(
switchMap(([file]) => loadFileContent(file)),
map((content) =>
parseCsv(content, { header: this.hasHeaderControl.value || false, delimiter: ';' }),
),
tap((d) => {
if (!d.errors.length) {
return;
}
if (d.errors.length === 1) {
this.log.error(d.errors[0]);
}
this.log.error(new Error(d.errors.map((e) => e.message).join('. ')));
}),
map((d) => {
const chargebacks = unifyCsvItems(d?.data, CSV_CHARGEBACK_PROPS);
if (chargebacks[0].invoice_id) {
return chargebacks;
}
this.log.error(
'Perhaps you incorrectly checked the checkbox to have or not a header (the first element does not have at least an invoice ID)',
);
return [];
}),
map((chargebacks) =>
chargebacks.map((c) => ({
invoiceId: c.invoice_id as string,
paymentId: c.payment_id as string,
params: csvChargebacksToInvoicePaymentChargebackParams(c),
})),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
selected: CsvChargeback[] = [];
successfullyChargebacks: InvoicePaymentChargeback[] = [];
props = CSV_CHARGEBACK_PROPS;
constructor(
private invoicingService: InvoicingService,
private log: NotifyLogService,
private amountCurrencyService: AmountCurrencyService,
private destroyRef: DestroyRef,
) {
super();
}
ngOnInit() {
this.chargebacks$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((c) => {
this.selectedChargebacks = c || [];
});
}
create() {
const selected = this.selectedChargebacks;
const selected = this.selected;
forkJoinToResult(
selected.map((c) =>
this.invoicingService.CreateChargeback(c.invoiceId, c.paymentId, c.params),
this.invoicingService.CreateChargeback(...getCreateChargebackArgs(c)),
),
this.progress$,
)
@ -172,9 +58,7 @@ export class CreateChargebacksByFileDialogComponent
chargebacksWithError.map((c) => c.error),
`Creating ${chargebacksWithError.length} chargebacks ended in an error. They were re-selected in the table.`,
);
this.selectedChargebacks = chargebacksWithError.map(
(c) => selected[c.index],
);
this.selected = chargebacksWithError.map((c) => selected[c.index]);
} else {
this.log.successOperation('create', 'chargebacks');
this.closeWithSuccess();
@ -184,10 +68,6 @@ export class CreateChargebacksByFileDialogComponent
});
}
async loadFile(file: File) {
this.upload$.next(file);
}
override closeWithSuccess() {
super.closeWithSuccess(this.successfullyChargebacks);
}

View File

@ -1,24 +0,0 @@
export const CSV_CHARGEBACK_PROPS = [
'invoice_id',
'payment_id',
'reason.category',
'reason.code',
'levy.amount',
'levy.currency.symbolic_code',
// Optional
'body.amount',
'body.currency.symbolic_code',
'external_id',
'occurred_at',
'context.type',
'context.data',
'transaction_info.id',
'transaction_info.timestamp',
'transaction_info.extra',
] as const;

View File

@ -1,5 +1,35 @@
import { CsvItem } from '@cc/utils';
import { DeepReadonly } from 'utility-types';
import { CSV_CHARGEBACK_PROPS } from './csv-chargeback-props';
import { CsvProps, CsvObject } from '../../../../../../components/upload-csv';
export type CsvChargeback = CsvItem<(typeof CSV_CHARGEBACK_PROPS)[number]>;
export const CSV_CHARGEBACK_PROPS = {
required: [
'invoice_id',
'payment_id',
'reason.category',
'reason.code',
'levy.amount',
'levy.currency.symbolic_code',
],
optional: [
'body.amount',
'body.currency.symbolic_code',
'external_id',
'occurred_at',
'context.type',
'context.data',
'transaction_info.id',
'transaction_info.timestamp',
'transaction_info.extra',
],
} as const satisfies DeepReadonly<CsvProps>;
export type CsvChargeback = CsvObject<
(typeof CSV_CHARGEBACK_PROPS)['required'][number],
(typeof CSV_CHARGEBACK_PROPS)['optional'][number]
>;

View File

@ -1,57 +0,0 @@
import { InvoicePaymentChargebackParams } from '@vality/domain-proto/payment_processing';
import { clean } from '@vality/ng-core';
import short from 'short-uuid';
import { CsvChargeback } from '../types/csv-chargeback';
export function csvChargebacksToInvoicePaymentChargebackParams(
c: CsvChargeback,
): InvoicePaymentChargebackParams {
return clean(
{
id: short().generate(),
reason: {
code: c['reason.code'],
category: { [c['reason.category']]: {} },
},
levy: {
amount: Number(c['levy.amount']),
currency: {
symbolic_code: c['levy.currency.symbolic_code'],
},
},
body:
(c['body.amount'] || typeof c['body.amount'] === 'number') &&
c['body.currency.symbolic_code']
? {
amount: Number(c['body.amount']),
currency: {
symbolic_code: c['body.currency.symbolic_code'],
},
}
: undefined,
transaction_info: clean(
{
id: c['transaction_info.id'],
timestamp: c['transaction_info.timestamp'],
extra: c['transaction_info.extra'],
additional_info: c['transaction_info.additional_info']
? JSON.parse(c['transaction_info.additional_info'])
: undefined,
},
true,
),
external_id: c.external_id,
context: clean(
{
type: c['context.type'],
data: c['context.data'],
},
true,
),
occurred_at: c['occurred_at'],
},
false,
true,
);
}

View File

@ -0,0 +1,63 @@
import { CodegenClient } from '@vality/domain-proto/internal/payment_processing-Invoicing';
import { clean } from '@vality/ng-core';
import isNil from 'lodash-es/isNil';
import short from 'short-uuid';
import { CsvChargeback } from '../types/csv-chargeback';
export function getCreateChargebackArgs(
c: CsvChargeback,
): Parameters<CodegenClient['CreateChargeback']> {
return [
c.invoice_id,
c.payment_id,
clean(
{
id: short().generate(),
reason: {
code: c['reason.code'],
category: { [c['reason.category']]: {} },
},
levy: {
amount: Number(c['levy.amount']),
currency: {
symbolic_code: c['levy.currency.symbolic_code'],
},
},
body:
!isNil(c['body.amount']) && c['body.currency.symbolic_code']
? {
amount: Number(c['body.amount']),
currency: {
symbolic_code: c['body.currency.symbolic_code'],
},
}
: undefined,
transaction_info: clean(
{
id: c['transaction_info.id'],
timestamp: c['transaction_info.timestamp'],
extra: c['transaction_info.extra']
? new Map(JSON.parse(c['transaction_info.extra']))
: undefined,
additional_info: c['transaction_info.additional_info']
? JSON.parse(c['transaction_info.additional_info'])
: undefined,
},
true,
),
external_id: c.external_id,
context: clean(
{
type: c['context.type'],
data: c['context.data'],
},
true,
),
occurred_at: c['occurred_at'],
},
false,
true,
),
];
}

View File

@ -4,7 +4,7 @@
[extensions]="extensions"
[formControl]="control"
namespace="deposit"
noChangeKind
noToolbar
type="DepositParams"
></cc-fistful-thrift-form>
<v-dialog-actions>

View File

@ -8,11 +8,11 @@ import { BehaviorSubject, of } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { Overwrite } from 'utility-types';
import { SourceCash } from '../../../../components/source-cash-field';
import { DepositManagementService } from '../../../api/deposit';
import { MetadataFormExtension } from '../../../shared/components/metadata-form';
import { UserInfoBasedIdGeneratorService } from '../../../shared/services';
import { FetchSourcesService } from '../../sources';
import { SourceCash } from '../../../../../components/source-cash-field';
import { DepositManagementService } from '../../../../api/deposit';
import { MetadataFormExtension } from '../../../../shared/components/metadata-form';
import { UserInfoBasedIdGeneratorService } from '../../../../shared/services';
import { FetchSourcesService } from '../../../sources';
@Component({
templateUrl: 'create-deposit-dialog.component.html',
@ -57,17 +57,19 @@ export class CreateDepositDialogComponent extends DialogSuperclass<CreateDeposit
(sources) =>
sources.find((s) => s.id === sourceCash.sourceId).currency_symbolic_code,
),
map(
(symbolicCode): DepositParams => ({
...value,
source_id: sourceCash.sourceId,
body: {
amount: sourceCash.amount,
currency: { symbolic_code: symbolicCode },
switchMap((symbolicCode) =>
this.depositManagementService.Create(
{
...value,
source_id: sourceCash.sourceId,
body: {
amount: sourceCash.amount,
currency: { symbolic_code: symbolicCode },
},
},
}),
new Map(),
),
),
switchMap((params) => this.depositManagementService.Create(params, new Map())),
progressTo(this.progress$),
takeUntilDestroyed(this.destroyRef),
)

View File

@ -9,10 +9,10 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { DialogModule } from '@vality/ng-core';
import { SourceCashFieldComponent } from '../../../../components/source-cash-field';
import { CurrencySourceFieldComponent } from '../../../shared/components/currency-source-field';
import { FistfulThriftFormComponent } from '../../../shared/components/fistful-thrift-form';
import { UserInfoBasedIdGeneratorModule } from '../../../shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
import { SourceCashFieldComponent } from '../../../../../components/source-cash-field';
import { CurrencySourceFieldComponent } from '../../../../shared/components/currency-source-field';
import { FistfulThriftFormComponent } from '../../../../shared/components/fistful-thrift-form';
import { UserInfoBasedIdGeneratorModule } from '../../../../shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
import { CreateDepositDialogComponent } from './create-deposit-dialog.component';

View File

@ -0,0 +1,21 @@
<v-dialog [progress]="progress$ | async" title="Create deposits">
<cc-upload-csv [props]="props" (selectedChange)="selected = $event"></cc-upload-csv>
<v-dialog-actions>
<button
*ngIf="successfully?.length"
[disabled]="!!(progress$ | async)"
mat-button
(click)="closeWithSuccess()"
>
Close and find {{ successfully.length }} successful deposits
</button>
<button
[disabled]="!selected?.length || !!(progress$ | async)"
color="primary"
mat-button
(click)="create()"
>
Create
</button>
</v-dialog-actions>
</v-dialog>

View File

@ -0,0 +1,76 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { DepositState } from '@vality/fistful-proto/internal/deposit';
import {
DialogSuperclass,
NotifyLogService,
DEFAULT_DIALOG_CONFIG,
forkJoinToResult,
DialogModule,
} from '@vality/ng-core';
import { BehaviorSubject } from 'rxjs';
import { UploadCsvComponent } from '../../../../../components/upload-csv';
import { DepositManagementService } from '../../../../api/deposit';
import { CSV_DEPOSIT_PROPS, CsvDeposit } from './types/csv-deposit';
import { getCreateDepositArgs } from './utils/get-create-deposit-args';
@Component({
standalone: true,
selector: 'cc-create-deposits-by-file-dialog',
templateUrl: './create-deposits-by-file-dialog.component.html',
imports: [DialogModule, UploadCsvComponent, CommonModule, MatButton],
})
export class CreateDepositsByFileDialogComponent extends DialogSuperclass<
CreateDepositsByFileDialogComponent,
void,
DepositState[]
> {
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
progress$ = new BehaviorSubject(0);
selected: CsvDeposit[] = [];
successfully: DepositState[] = [];
props = CSV_DEPOSIT_PROPS;
constructor(
private depositManagementService: DepositManagementService,
private log: NotifyLogService,
private destroyRef: DestroyRef,
) {
super();
}
create() {
const selected = this.selected;
forkJoinToResult(
selected.map((c) => this.depositManagementService.Create(...getCreateDepositArgs(c))),
this.progress$,
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res) => {
this.successfully.push(...res.filter((c) => !c.hasError).map((c) => c.result));
const withError = res.filter((c) => c.hasError);
if (withError.length) {
this.log.error(
withError.map((c) => c.error),
`Creating ${withError.length} deposits ended in an error. They were re-selected in the table.`,
);
this.selected = withError.map((c) => selected[c.index]);
} else {
this.log.successOperation('create', 'deposits');
this.closeWithSuccess();
}
},
error: (err) => this.log.error(err),
});
}
override closeWithSuccess() {
super.closeWithSuccess(this.successfully);
}
}

View File

@ -0,0 +1,13 @@
import { DeepReadonly } from 'utility-types';
import { CsvProps, CsvObject } from '../../../../../../components/upload-csv';
export const CSV_DEPOSIT_PROPS = {
required: ['wallet_id', 'source_id', 'body.amount', 'body.currency'],
optional: ['external_id', 'description', 'metadata'],
} as const satisfies DeepReadonly<CsvProps>;
export type CsvDeposit = CsvObject<
(typeof CSV_DEPOSIT_PROPS)['required'][number],
(typeof CSV_DEPOSIT_PROPS)['optional'][number]
>;

View File

@ -0,0 +1,29 @@
import { inject } from '@angular/core';
import { CodegenClient } from '@vality/fistful-proto/internal/deposit-Management';
import { clean } from '@vality/ng-core';
import { UserInfoBasedIdGeneratorService } from '../../../../../shared/services';
import { CsvDeposit } from '../types/csv-deposit';
export function getCreateDepositArgs(c: CsvDeposit): Parameters<CodegenClient['Create']> {
const userInfoBasedIdGeneratorService = inject(UserInfoBasedIdGeneratorService);
return [
clean(
{
id: userInfoBasedIdGeneratorService.getUsernameBasedId(),
wallet_id: c.wallet_id,
source_id: c.source_id,
body: {
amount: Number(c['body.amount']),
currency: { symbolic_code: c['body.currency'] },
},
external_id: c.external_id,
metadata: c.metadata ? JSON.parse(c.metadata) : undefined,
description: c.description,
},
false,
true,
),
new Map(),
];
}

View File

@ -31,6 +31,9 @@
(update)="reload($event)"
>
<v-table-actions>
<button color="primary" mat-raised-button (click)="createByFile()">
Create by file
</button>
<button color="primary" mat-raised-button (click)="createDeposit()">Create</button>
</v-table-actions>
</v-table>

View File

@ -27,7 +27,8 @@ import { QueryDsl } from '../../api/fistful-stat';
import { createCurrencyColumn } from '../../shared';
import { DATE_RANGE_DAYS, DEBOUNCE_TIME_MS } from '../../tokens';
import { CreateDepositDialogComponent } from './create-deposit-dialog/create-deposit-dialog.component';
import { CreateDepositDialogComponent } from './components/create-deposit-dialog/create-deposit-dialog.component';
import { CreateDepositsByFileDialogComponent } from './components/create-deposits-by-file-dialog/create-deposits-by-file-dialog.component';
import { FetchDepositsService } from './services/fetch-deposits/fetch-deposits.service';
const REVERT_STATUS: { [N in RevertStatus]: string } = {
@ -133,13 +134,13 @@ export class DepositsComponent implements OnInit {
@Inject(DATE_RANGE_DAYS) private dateRangeDays: number,
@Inject(DEBOUNCE_TIME_MS) private debounceTimeMs: number,
private qp: QueryParamsService<object>,
private destroyRef: DestroyRef,
private dr: DestroyRef,
) {}
ngOnInit() {
this.filtersForm.patchValue(this.qp.params);
getValueChanges(this.filtersForm)
.pipe(debounceTimeWithFirst(this.debounceTimeMs), takeUntilDestroyed(this.destroyRef))
.pipe(debounceTimeWithFirst(this.debounceTimeMs), takeUntilDestroyed(this.dr))
.subscribe(() => {
this.update();
});
@ -151,7 +152,7 @@ export class DepositsComponent implements OnInit {
.afterClosed()
.pipe(
filter((res) => res.status === DialogResponseStatus.Success),
takeUntilDestroyed(this.destroyRef),
takeUntilDestroyed(this.dr),
)
.subscribe(() => {
this.update();
@ -178,4 +179,19 @@ export class DepositsComponent implements OnInit {
more() {
this.fetchDepositsService.more();
}
createByFile() {
this.dialog
.open(CreateDepositsByFileDialogComponent)
.afterClosed()
.pipe(
filter((res) => res.status === DialogResponseStatus.Success),
takeUntilDestroyed(this.dr),
)
.subscribe(() => {
this.filtersForm.reset({
dateRange: createDateRangeToToday(this.dateRangeDays),
});
});
}
}

View File

@ -23,7 +23,7 @@ import { PageLayoutModule, WalletFieldModule } from '../../shared';
import { CurrencyFieldComponent } from '../../shared/components/currency-field';
import { MerchantFieldModule } from '../../shared/components/merchant-field';
import { CreateDepositDialogModule } from './create-deposit-dialog/create-deposit-dialog.module';
import { CreateDepositDialogModule } from './components/create-deposit-dialog/create-deposit-dialog.module';
import { DepositsRoutingModule } from './deposits-routing.module';
import { DepositsComponent } from './deposits.component';

View File

@ -0,0 +1 @@
export * from './upload-csv.component';

View File

@ -0,0 +1,46 @@
<div style="display: flex; flex-direction: column; gap: 16px">
<mat-checkbox [formControl]="hasHeaderControl">CSV with header</mat-checkbox>
<v-file-upload [extensions]="['csv']" (upload)="loadFile($event)"></v-file-upload>
<mat-accordion>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Default format</mat-panel-title>
</mat-expansion-panel-header>
<div class="mat-body-1">
Header for CSV file (or if without it, then the order should be like this):
</div>
<div class="mat-body-1">
<code
*ngIf="props().required"
style="word-wrap: break-word; word-break: break-word; font-weight: bold"
>{{ props().required.join(delimiter) }}</code
><code
*ngIf="props().optional"
style="word-wrap: break-word; word-break: break-word; font-style: italic"
>{{ props().required && props().optional ? delimiter : '' }}</code
><code
*ngIf="props().optional"
style="word-wrap: break-word; word-break: break-word; font-style: italic"
>{{ props().optional.join(delimiter) }}</code
>
</div>
<div class="mat-body-1">
<ul>
<li>Required columns are indicated in bold.</li>
<li>Separator: "{{ delimiter }}"</li>
<li *ngFor="let desc of formatDescription() || []">{{ desc }}</li>
</ul>
</div>
</mat-expansion-panel>
</mat-accordion>
<v-table
[(rowSelected)]="selectedCsv"
[columns]="columns()"
[data]="data$ | async"
noActions
rowSelectable
></v-table>
</div>

View File

@ -0,0 +1,148 @@
import { CommonModule } from '@angular/common';
import {
Component,
Output,
EventEmitter,
type OnInit,
input,
computed,
DestroyRef,
model,
Injector,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatAccordion, MatExpansionModule } from '@angular/material/expansion';
import {
FileUploadModule,
TableModule,
loadFileContent,
getValueChanges,
NotifyLogService,
Column,
} from '@vality/ng-core';
import startCase from 'lodash-es/startCase';
import { BehaviorSubject, combineLatest, merge } from 'rxjs';
import { switchMap, map, tap, shareReplay } from 'rxjs/operators';
import { parseCsv, unifyCsvItems } from '../../utils';
const DEFAULT_DELIMITER = ';';
export type CsvProps<R extends string = string, O extends string = string> = {
required?: R[];
optional?: O[];
};
export type CsvObject<R extends string = string, O extends string = string> = Record<R, string> &
Partial<Record<O, string>>;
function getCsvObjectErrors<R extends string, O extends string>(
props: CsvProps<R, O>,
data: CsvObject<R, O>[],
): null | { required: R[] } {
const needRequiredProps = new Set<R>();
for (const item of data) {
for (const p of props.required ?? []) {
if (!(p in item)) {
needRequiredProps.add(p);
}
}
}
return needRequiredProps.size ? { required: Array.from(needRequiredProps) } : null;
}
@Component({
selector: 'cc-upload-csv',
standalone: true,
imports: [
MatCheckbox,
ReactiveFormsModule,
MatAccordion,
FileUploadModule,
MatExpansionModule,
TableModule,
CommonModule,
],
templateUrl: './upload-csv.component.html',
styles: ``,
})
export class UploadCsvComponent<R extends string, O extends string> implements OnInit {
props = input<CsvProps<R, O>>({});
formatDescription = input<string[]>();
selected = input<CsvObject<R, O>[]>([]);
@Output() selectedChange = new EventEmitter<CsvObject<R, O>[]>();
delimiter = DEFAULT_DELIMITER;
propsList = computed<string[]>(() => [
...(this.props().required ?? []),
...(this.props().optional ?? []),
]);
selectedCsv = model<CsvObject<R, O>[]>();
hasHeaderControl = new FormControl(null, { nonNullable: true });
upload$ = new BehaviorSubject<File | null>(null);
data$ = combineLatest([this.upload$, getValueChanges(this.hasHeaderControl)]).pipe(
switchMap(([file]) => loadFileContent(file)),
map((content) =>
parseCsv(content, {
header: this.hasHeaderControl.value || false,
delimiter: this.delimiter,
}),
),
tap((d) => {
if (!d.errors.length) {
return;
}
if (d.errors.length === 1) {
this.log.error(d.errors[0]);
}
this.log.error(new Error(d.errors.map((e) => e.message).join('. ')));
}),
map((d) => {
const data: CsvObject<R, O>[] = unifyCsvItems(d?.data, this.propsList());
const errors = getCsvObjectErrors(this.props(), data);
if (!errors) {
return data;
}
this.log.error(
`Missing required properties: ${errors.required.join(
', ',
)}. Perhaps you incorrectly checked the checkbox to have or not a header (the first element does not have at least an invoice ID).`,
);
return [];
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
columns = computed<Column<CsvObject<R, O>>[]>(() =>
this.propsList().map((p) => ({
field: p,
header: startCase(p),
})),
);
constructor(
private log: NotifyLogService,
private dr: DestroyRef,
private injector: Injector,
) {}
ngOnInit() {
merge(this.data$, toObservable(this.selected, { injector: this.injector }))
.pipe(takeUntilDestroyed(this.dr))
.subscribe((v) => {
this.selectedCsv.set(v || []);
});
toObservable(this.selectedCsv, { injector: this.injector })
.pipe(takeUntilDestroyed(this.dr))
.subscribe((v) => {
this.selectedChange.emit(v);
});
}
async loadFile(file: File) {
this.upload$.next(file);
}
}

View File

@ -1,3 +1,2 @@
export * from './parse-csv';
export * from './types/csv-item';
export * from './utils/unify-csv-items';

View File

@ -1,2 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CsvItem<TPropertyKey extends string = string> = Record<TPropertyKey[number], any>;