diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index b1794cb4a..59e731ee0 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,13 +1,14 @@ import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto'; import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto'; import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto'; +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component'; -import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component'; -import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module'; -import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; +import { GfDialogFooterComponent } from '../../../../components/dialog-footer/dialog-footer.component'; +import { GfDialogHeaderComponent } from '../../../../components/dialog-header/dialog-header.component'; +import { GfFileDropModule } from '../../../../directives/file-drop/file-drop.module'; +import { GfSymbolModule } from '../../../../pipes/symbol/symbol.module'; +import { DataService } from '../../../../services/data.service'; +import { ImportActivitiesService } from '../../../../services/import-activities.service'; import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; @@ -209,7 +210,13 @@ export class GfImportActivitiesDialog implements OnDestroy { return; } - this.handleFile({ stepper, file: files[0] }); + if (files.length === 1) { + // Single file import (original behavior) + this.handleFile({ stepper, file: files[0] }); + } else { + // Multiple files import (bulk import) + this.handleMultipleFiles({ stepper, files }); + } } public onImportStepChange(event: StepperSelectionEvent) { @@ -257,11 +264,19 @@ export class GfImportActivitiesDialog implements OnDestroy { const input = document.createElement('input'); input.accept = 'application/JSON, .csv'; input.type = 'file'; + input.multiple = true; // Allow multiple file selection input.onchange = (event) => { - // Getting the file reference - const file = (event.target as HTMLInputElement).files[0]; - this.handleFile({ file, stepper }); + const files = (event.target as HTMLInputElement).files; + if (files && files.length > 0) { + if (files.length === 1) { + // Single file import (original behavior) + this.handleFile({ file: files[0], stepper }); + } else { + // Multiple files import (bulk import) + this.handleMultipleFiles({ stepper, files }); + } + } }; input.click(); @@ -278,6 +293,127 @@ export class GfImportActivitiesDialog implements OnDestroy { this.unsubscribeSubject.complete(); } + private async handleMultipleFiles({ + files, + stepper + }: { + files: FileList; + stepper: MatStepper; + }): Promise { + this.snackBar.open(`⏳ Processing ${files.length} files...`); + + const allActivities: CreateOrderDto[] = []; + const allAccounts: CreateAccountWithBalancesDto[] = []; + const allAssetProfiles: CreateAssetProfileWithMarketDataDto[] = []; + const allTags: CreateTagDto[] = []; + let filesProcessed = 0; + let hasErrors = false; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + + if (fileExtension !== 'json') { + continue; + } + + try { + const fileContent = await this.readFileContent(file); + const content = JSON.parse(fileContent); + + // Merge accounts, avoiding duplicates + if (content.accounts) { + content.accounts.forEach((account: CreateAccountWithBalancesDto) => { + if (!allAccounts.find(a => a.id === account.id)) { + allAccounts.push(account); + } + }); + } + + // Merge asset profiles, avoiding duplicates + if (content.assetProfiles) { + content.assetProfiles.forEach((profile: CreateAssetProfileWithMarketDataDto) => { + if (!allAssetProfiles.find(p => p.symbol === profile.symbol)) { + allAssetProfiles.push(profile); + } + }); + } + + // Merge tags, avoiding duplicates + if (content.tags) { + content.tags.forEach((tag: CreateTagDto) => { + if (!allTags.find(t => t.id === tag.id)) { + allTags.push(tag); + } + }); + } + + // Add activities + if (isArray(content.activities)) { + const cleanedActivities = content.activities.map((activity: any) => { + if (activity.id) { + delete activity.id; + } + return activity as CreateOrderDto; + }); + allActivities.push(...cleanedActivities); + } + + filesProcessed++; + this.snackBar.open(`⏳ Processed ${filesProcessed}/${files.length} files...`); + + } catch (error) { + hasErrors = true; + } + } + + if (hasErrors) { + this.snackBar.open(`⚠️ Some files could not be processed`); + } + + // Store merged data + this.accounts = allAccounts; + this.assetProfiles = allAssetProfiles; + this.tags = allTags; + + try { + // Validate all activities at once + const { activities } = await this.importActivitiesService.importJson({ + accounts: allAccounts, + activities: allActivities, + assetProfiles: allAssetProfiles, + isDryRun: true, + tags: allTags + }); + + this.activities = activities; + this.dataSource = new MatTableDataSource(activities.reverse()); + this.totalItems = activities.length; + + this.snackBar.open(`✅ Successfully processed ${filesProcessed} files with ${activities.length} activities`); + + } catch (error) { + this.handleImportError({ error, activities: allActivities as any }); + this.snackBar.open(`❌ Validation failed for bulk import`); + } finally { + this.importStep = ImportStep.SELECT_ACTIVITIES; + this.snackBar.dismiss(); + this.updateSelection(this.activities); + + stepper.next(); + this.changeDetectorRef.markForCheck(); + } + } + + private readFileContent(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => resolve(event.target?.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file, 'UTF-8'); + }); + } + private async handleFile({ file, stepper diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 6b048e6c0..a72762f65 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -80,7 +80,8 @@ class="align-items-center d-flex flex-column justify-content-center" > - Choose or drop a file here + Choose or drop file(s) here + Multiple JSON files supported for bulk import