mirror of https://github.com/ghostfolio/ghostfolio
78 changed files with 4088 additions and 3051 deletions
@ -0,0 +1,74 @@ |
|||
import { |
|||
NON_INVESTMENT_ACTIVITY_TYPES, |
|||
TAG_ID_DRAFT |
|||
} from '@ghostfolio/common/config'; |
|||
|
|||
import { Prisma, Type as ActivityType } from '@prisma/client'; |
|||
import { endOfToday, isAfter } from 'date-fns'; |
|||
import { uniqBy } from 'lodash'; |
|||
|
|||
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = { |
|||
tags: { |
|||
none: { |
|||
id: TAG_ID_DRAFT |
|||
} |
|||
} |
|||
}; |
|||
|
|||
export function getTagsWithDraftTag<T extends { id: string }>({ |
|||
date, |
|||
draftTag, |
|||
endOfTodayDate = endOfToday(), |
|||
originalDate, |
|||
tags, |
|||
type |
|||
}: { |
|||
date: Date; |
|||
draftTag: T; |
|||
endOfTodayDate?: Date; |
|||
originalDate?: Date; |
|||
tags: T[]; |
|||
type: ActivityType; |
|||
}) { |
|||
if (!isDraftTagToBeAssigned({ date, endOfTodayDate, originalDate, type })) { |
|||
return tags; |
|||
} |
|||
|
|||
return uniqBy([...tags, draftTag], 'id'); |
|||
} |
|||
|
|||
export function isActivityInFuture({ |
|||
date, |
|||
endOfTodayDate = endOfToday() |
|||
}: { |
|||
date: Date; |
|||
endOfTodayDate?: Date; |
|||
}) { |
|||
return isAfter(date, endOfTodayDate); |
|||
} |
|||
|
|||
export function isDraftTagToBeAssigned({ |
|||
date, |
|||
endOfTodayDate = endOfToday(), |
|||
originalDate, |
|||
type |
|||
}: { |
|||
date: Date; |
|||
endOfTodayDate?: Date; |
|||
originalDate?: Date; |
|||
type: ActivityType; |
|||
}) { |
|||
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(type)) { |
|||
return false; |
|||
} |
|||
|
|||
if (!isActivityInFuture({ date, endOfTodayDate })) { |
|||
return false; |
|||
} |
|||
|
|||
// Assign only when the date newly moves into the future, so that a tag the
|
|||
// user has removed is not restored by an unrelated change
|
|||
return originalDate |
|||
? !isActivityInFuture({ endOfTodayDate, date: originalDate }) |
|||
: true; |
|||
} |
|||
@ -1,9 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
.mat-button-toggle-group { |
|||
.mat-button-toggle-appearance-standard { |
|||
--mat-button-toggle-height: 1.5rem; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,296 @@ |
|||
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; |
|||
import { |
|||
AccountDetailDialogParams, |
|||
AccountDetailDialogResult |
|||
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; |
|||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; |
|||
import { AccountResponse, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
|
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
computed, |
|||
DestroyRef, |
|||
OnDestroy, |
|||
OnInit, |
|||
inject |
|||
} from '@angular/core'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { Observable, of, Subject } from 'rxjs'; |
|||
import { |
|||
distinctUntilChanged, |
|||
map, |
|||
switchMap, |
|||
takeUntil, |
|||
tap |
|||
} from 'rxjs/operators'; |
|||
|
|||
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component'; |
|||
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces'; |
|||
import { AccountDialogMode } from './types/account-dialog-mode.type'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
selector: 'gf-account-dialog-host', |
|||
template: '' |
|||
}) |
|||
export class GfAccountDialogHostComponent implements OnDestroy, OnInit { |
|||
private dialogRef: MatDialogRef< |
|||
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent |
|||
>; |
|||
|
|||
private readonly deviceType = computed(() => { |
|||
return this.deviceDetectorService.deviceInfo().deviceType; |
|||
}); |
|||
|
|||
private readonly dialogClosed = new Subject<void>(); |
|||
|
|||
private readonly dataService = inject(DataService); |
|||
private readonly destroyRef = inject(DestroyRef); |
|||
private readonly deviceDetectorService = inject(DeviceDetectorService); |
|||
private readonly dialog = inject(MatDialog); |
|||
private readonly impersonationStorageService = inject( |
|||
ImpersonationStorageService |
|||
); |
|||
private readonly route = inject(ActivatedRoute); |
|||
private readonly router = inject(Router); |
|||
private readonly userService = inject(UserService); |
|||
|
|||
public ngOnInit() { |
|||
const mode = this.route.snapshot.data.mode as AccountDialogMode; |
|||
|
|||
// The router reuses this component when only the account id changes, so
|
|||
// the parameters are observed instead of read from the snapshot once
|
|||
this.route.paramMap |
|||
.pipe( |
|||
map((paramMap) => { |
|||
return paramMap.get('accountId'); |
|||
}), |
|||
distinctUntilChanged(), |
|||
tap(() => { |
|||
this.closeDialog(); |
|||
}), |
|||
switchMap((accountId) => { |
|||
const account$: Observable<AccountResponse | undefined> = |
|||
mode === 'update' && accountId |
|||
? this.dataService.fetchAccount(accountId) |
|||
: of(undefined); |
|||
|
|||
return this.userService.get().pipe( |
|||
switchMap((user) => { |
|||
return account$.pipe( |
|||
map((account) => { |
|||
return { account, accountId, user }; |
|||
}) |
|||
); |
|||
}) |
|||
); |
|||
}), |
|||
takeUntilDestroyed(this.destroyRef) |
|||
) |
|||
.subscribe({ |
|||
error: () => { |
|||
this.navigateBack(); |
|||
}, |
|||
next: ({ account, accountId, user }) => { |
|||
if (mode === 'detail') { |
|||
this.openAccountDetailDialog({ accountId, user }); |
|||
|
|||
return; |
|||
} |
|||
|
|||
if (mode === 'update') { |
|||
if ( |
|||
!account || |
|||
!hasPermission(user?.permissions, permissions.updateAccount) || |
|||
this.isReadOnlyMode(user) |
|||
) { |
|||
this.navigateBack(); |
|||
|
|||
return; |
|||
} |
|||
|
|||
const { balance, comment, currency, id, name, platformId, tags } = |
|||
account; |
|||
|
|||
this.openCreateOrUpdateAccountDialog({ |
|||
user, |
|||
account: { |
|||
balance, |
|||
comment, |
|||
currency, |
|||
id, |
|||
name, |
|||
platformId, |
|||
tags |
|||
}, |
|||
isUpdate: true |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
|
|||
if ( |
|||
!hasPermission(user?.permissions, permissions.createAccount) || |
|||
this.isReadOnlyMode(user) |
|||
) { |
|||
this.navigateBack(); |
|||
|
|||
return; |
|||
} |
|||
|
|||
this.openCreateOrUpdateAccountDialog({ |
|||
user, |
|||
account: { |
|||
balance: 0, |
|||
comment: null, |
|||
currency: user?.settings?.baseCurrency ?? null, |
|||
id: null, |
|||
name: null, |
|||
platformId: null, |
|||
tags: [] |
|||
}, |
|||
isUpdate: false |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
// The dialog lives in an overlay outside of this component, so it needs to
|
|||
// be closed explicitly when leaving the route (for example via the browser
|
|||
// navigation)
|
|||
this.dialogRef?.close(); |
|||
|
|||
this.dialogClosed.complete(); |
|||
} |
|||
|
|||
private closeDialog() { |
|||
// Tear down the subscription of the dialog which is about to be replaced,
|
|||
// so that its result is not mistaken for the user closing it
|
|||
this.dialogClosed.next(); |
|||
|
|||
this.dialogRef?.close(); |
|||
} |
|||
|
|||
private isReadOnlyMode(user: User) { |
|||
return ( |
|||
!!this.impersonationStorageService.getId() || |
|||
!!user?.settings?.isRestrictedView |
|||
); |
|||
} |
|||
|
|||
private navigateBack() { |
|||
void this.router.navigate(internalRoutes.accounts.routerLink); |
|||
} |
|||
|
|||
private openAccountDetailDialog({ |
|||
accountId, |
|||
user |
|||
}: { |
|||
accountId: string | null; |
|||
user: User; |
|||
}) { |
|||
if (!accountId) { |
|||
this.navigateBack(); |
|||
|
|||
return; |
|||
} |
|||
|
|||
const impersonationId = this.impersonationStorageService.getId(); |
|||
|
|||
const dialogRef = this.dialog.open< |
|||
GfAccountDetailDialogComponent, |
|||
AccountDetailDialogParams, |
|||
AccountDetailDialogResult |
|||
>(GfAccountDetailDialogComponent, { |
|||
autoFocus: false, |
|||
data: { |
|||
accountId, |
|||
impersonationId, |
|||
deviceType: this.deviceType(), |
|||
hasPermissionToCreateActivity: |
|||
!impersonationId && |
|||
hasPermission(user?.permissions, permissions.createActivity) && |
|||
!user?.settings?.isRestrictedView |
|||
}, |
|||
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', |
|||
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
this.dialogRef = dialogRef; |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
|||
.subscribe((result) => { |
|||
if (result?.isNavigating) { |
|||
return; |
|||
} |
|||
|
|||
this.navigateBack(); |
|||
}); |
|||
} |
|||
|
|||
private openCreateOrUpdateAccountDialog({ |
|||
account, |
|||
isUpdate, |
|||
user |
|||
}: { |
|||
account: CreateOrUpdateAccountDialogParams['account']; |
|||
isUpdate: boolean; |
|||
user: User; |
|||
}) { |
|||
const dialogRef = this.dialog.open< |
|||
GfCreateOrUpdateAccountDialogComponent, |
|||
CreateOrUpdateAccountDialogParams, |
|||
CreateAccountDto | UpdateAccountDto | null |
|||
>(GfCreateOrUpdateAccountDialogComponent, { |
|||
data: { |
|||
account, |
|||
user |
|||
}, |
|||
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', |
|||
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
this.dialogRef = dialogRef; |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
|||
.subscribe((result) => { |
|||
if (!result) { |
|||
this.navigateBack(); |
|||
|
|||
return; |
|||
} |
|||
|
|||
const request$: Observable<unknown> = isUpdate |
|||
? this.dataService.putAccount(result as UpdateAccountDto) |
|||
: this.dataService.postAccount(result as CreateAccountDto); |
|||
|
|||
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ |
|||
error: () => { |
|||
this.navigateBack(); |
|||
}, |
|||
next: () => { |
|||
// Deliberately not bound to the destroy reference: navigating back
|
|||
// destroys this component and the refreshed user is what makes the
|
|||
// accounts page reload its data
|
|||
this.userService.get(true).subscribe(); |
|||
|
|||
this.navigateBack(); |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export type AccountDialogMode = 'create' | 'detail' | 'update'; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -1,4 +0,0 @@ |
|||
export interface ToggleOption { |
|||
label: string; |
|||
value: string; |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
interface BaseToggleOption { |
|||
value: string; |
|||
} |
|||
|
|||
export type ToggleOption = BaseToggleOption & |
|||
( |
|||
| { iconName: string; label?: never; title: string } |
|||
| { iconName?: never; label: string; title?: never } |
|||
); |
|||
@ -0,0 +1,13 @@ |
|||
-- Create the "DRAFT" tag if it does not exist yet |
|||
INSERT INTO "Tag" ("id", "name") |
|||
VALUES ('0c077abd-eca2-4cbb-818c-6cefbf2d169a', 'DRAFT') |
|||
ON CONFLICT DO NOTHING; |
|||
|
|||
-- Migrate activities with "isDraft" to the "DRAFT" tag |
|||
INSERT INTO "_OrderToTag" ("A", "B") |
|||
SELECT |
|||
"id", |
|||
'0c077abd-eca2-4cbb-818c-6cefbf2d169a' |
|||
FROM "Order" |
|||
WHERE "isDraft" = true |
|||
ON CONFLICT DO NOTHING; |
|||
Loading…
Reference in new issue