diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 007429a38..74cdf14f1 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -42,6 +42,7 @@ export class ExportService { accountId, date, fee, + id, quantity, SymbolProfile, type, @@ -49,13 +50,14 @@ export class ExportService { }) => { return { accountId, - date, fee, + id, quantity, type, unitPrice, currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, + date: date.toISOString(), symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol }; } diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index db4cbf471..3b3cd8da6 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts @@ -217,7 +217,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit { parseISO(data.meta.date), 'yyyyMMddHHmm' )}.json`, - 'text/plain' + 'text/plain', + 'json' ); }); } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index ba73739b7..fcfe7b8dd 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -11,11 +11,11 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; -import { User } from '@ghostfolio/common/interfaces'; +import { Export, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { DataSource, Order as OrderModel } from '@prisma/client'; +import { DataSource, Order as OrderModel, Type } from '@prisma/client'; import { format, parseISO } from 'date-fns'; -import { isArray } from 'lodash'; +import { capitalize, isArray } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -152,13 +152,35 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .fetchExport(activityIds) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data) => { + for (const activity of data.activities) { + delete activity.id; + } + downloadAsFile( data, `ghostfolio-export-${format( parseISO(data.meta.date), 'yyyyMMddHHmm' )}.json`, - 'text/plain' + 'text/plain', + 'json' + ); + }); + } + + public onExportDrafts(activityIds?: string[]) { + this.dataService + .fetchExport(activityIds) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + downloadAsFile( + this.getIcsContent(data.activities), + `ghostfolio-drafts-${format( + parseISO(data.meta.date), + 'yyyyMMddHHmm' + )}.ics`, + 'text/plain', + 'string' ); }); } @@ -293,6 +315,49 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + private getIcsContent(aActivities: Export['activities']) { + const header = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Ghostfolio//NONSGML v1.0//EN' + ]; + const events = aActivities.map((activity) => { + return this.getEvent({ + date: parseISO(activity.date), + id: activity.id, + symbol: activity.symbol, + type: activity.type + }); + }); + const footer = ['END:VCALENDAR']; + + return [...header, ...events, ...footer].join('\n'); + } + + private getEvent({ + date, + id, + symbol, + type + }: { + date: Date; + id: string; + symbol: string; + type: Type; + }) { + const today = format(new Date(), 'yyyyMMdd'); + + return [ + 'BEGIN:VEVENT', + `UID:${id}`, + `DTSTAMP:${today}T000000`, + `DTSTART;VALUE=DATE:${format(date, 'yyyyMMdd')}`, + `DTEND;VALUE=DATE:${format(date, 'yyyyMMdd')}`, + `SUMMARY:${capitalize(type)} ${symbol}`, + 'END:VEVENT' + ].join('\n'); + } + private handleImportError({ activities, error diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html index 0df0171b9..31d29b1df 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html @@ -15,6 +15,7 @@ (activityToClone)="onCloneTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)" (export)="onExport($event)" + (exportDrafts)="onExportDrafts($event)" (import)="onImport()" > diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 351643732..9406b4df2 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -15,10 +15,17 @@ export function decodeDataSource(encodedDataSource: string) { export function downloadAsFile( aContent: unknown, aFileName: string, - aContentType: string + aContentType: string, + aType: 'json' | 'string' ) { const a = document.createElement('a'); - const file = new Blob([JSON.stringify(aContent, undefined, ' ')], { + let content = aContent; + + if (aType === 'json') { + content = JSON.stringify(aContent, undefined, ' '); + } + + const file = new Blob([content], { type: aContentType }); a.href = URL.createObjectURL(file); diff --git a/libs/common/src/lib/interfaces/export.interface.ts b/libs/common/src/lib/interfaces/export.interface.ts index 48ddd2c98..37dbfba79 100644 --- a/libs/common/src/lib/interfaces/export.interface.ts +++ b/libs/common/src/lib/interfaces/export.interface.ts @@ -5,5 +5,14 @@ export interface Export { date: string; version: string; }; - activities: Partial[]; + activities: (Omit< + Order, + | 'accountUserId' + | 'createdAt' + | 'date' + | 'isDraft' + | 'symbolProfileId' + | 'updatedAt' + | 'userId' + > & { date: string; symbol: string })[]; } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index cbcb7da9c..7cbfffe05 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -356,11 +356,22 @@ *ngIf="hasPermissionToExportActivities" class="align-items-center d-flex" mat-menu-item + [disabled]="dataSource.data.length === 0" (click)="onExport()" > Export + diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 52bc841ff..98ca9e2bd 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -56,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @Output() activityToClone = new EventEmitter(); @Output() activityToUpdate = new EventEmitter(); @Output() export = new EventEmitter(); + @Output() exportDrafts = new EventEmitter(); @Output() import = new EventEmitter(); @ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @@ -68,6 +69,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { public endOfToday = endOfToday(); public filters$: Subject = new BehaviorSubject([]); public filters: Observable = this.filters$.asObservable(); + public hasDrafts = false; public isAfter = isAfter; public isLoading = true; public isUUID = isUUID; @@ -198,6 +200,18 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { } } + public onExportDrafts() { + this.exportDrafts.emit( + this.dataSource.filteredData + .filter((activity) => { + return activity.isDraft; + }) + .map((activity) => { + return activity.id; + }) + ); + } + public onImport() { this.import.emit(); } @@ -234,6 +248,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { this.filters$.next(this.allFilters); + this.hasDrafts = this.dataSource.data.some((activity) => { + return activity.isDraft === true; + }); this.totalFees = this.getTotalFees(); this.totalValue = this.getTotalValue(); }