Initial commit

This commit is contained in:
Thomas
2021-04-13 21:53:58 +02:00
commit c616312233
371 changed files with 31010 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@@ -0,0 +1,40 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["apps/client/tsconfig.*?.json"]
},
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
],
"plugins": ["@angular-eslint/eslint-plugin", "@typescript-eslint"],
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "gf",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "gf",
"style": "camelCase"
}
]
}
}

View File

@@ -0,0 +1,23 @@
module.exports = {
displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
}
},
coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js',
'jest-preset-angular/build/AngularSnapshotSerializer.js',
'jest-preset-angular/build/HTMLCommentSerializer.js'
]
};

View File

@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:3333",
"secure": false
}
}

View File

@@ -0,0 +1,65 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { format, isValid } from 'date-fns';
import * as deDateFnsLocale from 'date-fns/locale/de/index';
export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor(
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
) {
super(matDateLocale, platform);
}
/**
* Sets the first day of the week to Monday
*/
public getFirstDayOfWeek(): number {
return 1;
}
/**
* Formats a date as a string according to the given format
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, aParseFormat, {
locale: <any>deDateFnsLocale
});
}
/**
* Parses a date from a provided value
*/
public parse(aValue: any): Date {
let date: Date;
try {
// TODO
// Native date parser from the following formats:
// - 'd.M.yyyy'
// - 'dd.MM.yyyy'
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
const [, day, month, year] = datePattern.exec(aValue);
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // monthIndex
parseInt(day, 10)
);
} catch (error) {
} finally {
const isDateValid = date && isValid(date);
if (isDateValid) {
return date;
}
return null;
}
}
}

View File

@@ -0,0 +1,16 @@
import {
DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_MONTH_YEAR
} from 'libs/helper/src';
export const DateFormats = {
display: {
dateInput: DEFAULT_DATE_FORMAT,
monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR,
dateA11yLabel: DEFAULT_DATE_FORMAT,
monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR
},
parse: {
dateInput: DEFAULT_DATE_FORMAT
}
};

View File

@@ -0,0 +1,91 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ModulePreloadService } from './core/module-preload.service';
const routes: Routes = [
{
path: 'about',
loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
},
{
path: 'admin',
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{
path: 'account',
loadChildren: () =>
import('./pages/account/account-page.module').then(
(m) => m.AccountPageModule
)
},
{
path: 'auth',
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
},
{
path: 'analysis',
loadChildren: () =>
import('./pages/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'home',
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'report',
loadChildren: () =>
import('./pages/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'resources',
loadChildren: () =>
import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule
)
},
{
path: 'start',
loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
},
{
path: 'transactions',
loadChildren: () =>
import('./pages/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{
// wildcard, if requested url doesn't match any paths for routes defined
// earlier
path: '**',
redirectTo: '/home',
pathMatch: 'full'
}
];
@NgModule({
imports: [
RouterModule.forRoot(
routes,
// Preload all lazy loaded modules with the attribute preload === true
{
preloadingStrategy: ModulePreloadService,
// enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy'
}
)
],
providers: [ModulePreloadService],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,37 @@
<header>
<gf-header
class="position-fixed px-2 w-100"
[currentRoute]="currentRoute"
[user]="user"
></gf-header>
</header>
<main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5">
<div class="col-md-6 offset-md-3">
<div
class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</div>
</div>
</div>
</div>
<router-outlet></router-outlet>
</main>
<footer class="footer d-flex justify-content-center position-absolute w-100">
<div class="container text-center">
<div>Ghostfolio {{ version }}</div>
<div class="py-2 text-muted">
<small i18n
>The risk of loss in trading can be substantial. It is not advisable to
invest money you may need in the short term.</small
>
</div>
</div>
</footer>

View File

@@ -0,0 +1,24 @@
:host {
display: block;
main {
padding: 5rem 0;
.create-account-box {
border: 1px solid rgba(var(--palette-primary-500), 1);
border-radius: 0.25rem;
cursor: pointer;
font-size: 90%;
.link {
color: rgba(var(--palette-primary-500), 1);
}
}
}
.footer {
bottom: 0;
height: 5rem;
line-height: 1;
}
}

View File

@@ -0,0 +1,107 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { User } from 'apps/api/src/app/user/interfaces/user.interface';
import { formatDistanceToNow } from 'date-fns';
import { primaryColorHex, secondaryColorHex } from 'libs/helper/src';
import { hasPermission, permissions } from 'libs/helper/src';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
@Component({
selector: 'gf-root',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean;
public currentRoute: string;
public isLoggedIn = false;
public lastDataGathering: string;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private materialCssVarsService: MaterialCssVarsService,
private router: Router,
private tokenStorageService: TokenStorageService
) {
this.initializeTheme();
this.user = undefined;
}
public ngOnInit() {
this.dataService.fetchInfo().subscribe(({ lastDataGathering }) => {
this.lastDataGathering = lastDataGathering
? formatDistanceToNow(new Date(lastDataGathering), { addSuffix: true })
: '';
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe((test) => {
this.currentRoute = this.router.url.toString().substring(1);
// this.initializeTheme();
});
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.isLoggedIn = !!this.tokenStorageService.getToken();
if (this.isLoggedIn) {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createAccount
);
this.cd.markForCheck();
});
} else {
this.user = null;
}
});
}
private initializeTheme() {
this.materialCssVarsService.setDarkTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
this.materialCssVarsService.setDarkTheme(event.matches);
});
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
}
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@@ -0,0 +1,59 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
MatNativeDateModule
} from '@angular/material/core';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module';
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageManager } from './core/language-manager.service';
@NgModule({
declarations: [AppComponent],
imports: [
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
GfHeaderModule,
HttpClientModule,
MarkdownModule.forRoot(),
MatButtonModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
lightThemeClass: 'is-light-theme'
}),
MatNativeDateModule,
MatSnackBarModule,
NgxSkeletonLoaderModule
],
providers: [
authInterceptorProviders,
httpResponseInterceptorProviders,
LanguageManager,
{
provide: DateAdapter,
useClass: CustomDateAdapter,
deps: [LanguageManager, MAT_DATE_LOCALE, Platform]
},
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
],
bootstrap: [AppComponent]
})
export class AppModule {}

View File

@@ -0,0 +1,19 @@
<table mat-table [dataSource]="dataSource" class="w-100">
<ng-container matColumnDef="granteeAlias">
<th mat-header-cell *matHeaderCellDef i18n>User</th>
<td mat-cell *matCellDef="let element">
{{ element.granteeAlias }}
</td></ng-container
>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef i18n>Type</th>
<td mat-cell *matCellDef="let element">
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted Access
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Access } from 'apps/api/src/app/access/interfaces/access.interface';
@Component({
selector: 'gf-access-table',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss']
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
public dataSource: MatTableDataSource<Access>;
public displayedColumns = ['granteeAlias', 'type'];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { AccessTableComponent } from './access-table.component';
@NgModule({
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [CommonModule, MatTableModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioAccessTableModule {}

View File

@@ -0,0 +1,7 @@
<button
*ngIf="deviceType === 'mobile'"
mat-button
(click)="onClickCloseButton()"
>
<ion-icon name="close" size="large"></ion-icon>
</button>

View File

@@ -0,0 +1,4 @@
:host {
display: flex;
min-height: 0;
}

View File

@@ -0,0 +1,29 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output
} from '@angular/core';
@Component({
host: { class: 'justify-content-center' },
selector: 'gf-dialog-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-footer.component.html',
styleUrls: ['./dialog-footer.component.scss']
})
export class DialogFooterComponent implements OnInit {
@Input() deviceType: string;
@Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() {
this.closeButtonClicked.emit();
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { DialogFooterComponent } from './dialog-footer.component';
@NgModule({
declarations: [DialogFooterComponent],
exports: [DialogFooterComponent],
imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogFooterModule {}

View File

@@ -0,0 +1,9 @@
<span class="flex-grow-1 text-truncate">{{ title }}</span>
<button
*ngIf="deviceType !== 'mobile'"
class="no-min-width px-0"
mat-button
(click)="onClickCloseButton()"
>
<ion-icon name="close" size="large"></ion-icon>
</button>

View File

@@ -0,0 +1,3 @@
:host {
display: flex;
}

View File

@@ -0,0 +1,30 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output
} from '@angular/core';
@Component({
host: { class: 'justify-content-center' },
selector: 'gf-dialog-header',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-header.component.html',
styleUrls: ['./dialog-header.component.scss']
})
export class DialogHeaderComponent implements OnInit {
@Input() deviceType: string;
@Input() title: string;
@Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() {
this.closeButtonClicked.emit();
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { DialogHeaderComponent } from './dialog-header.component';
@NgModule({
declarations: [DialogHeaderComponent],
exports: [DialogHeaderComponent],
imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogHeaderModule {}

View File

@@ -0,0 +1,13 @@
<div class="align-items-center d-flex flex-row">
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div>
<div class="h3 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong
>/100</small
>
</div>
<small class="d-block" i18n>Market Mood</small>
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { resolveFearAndGreedIndex } from 'libs/helper/src';
@Component({
selector: 'gf-fear-and-greed-index',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './fear-and-greed-index.component.html',
styleUrls: ['./fear-and-greed-index.component.scss']
})
export class FearAndGreedIndexComponent implements OnChanges, OnInit {
@Input() fearAndGreedIndex: number;
public fearAndGreedIndexEmoji: string;
public fearAndGreedIndexText: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
this.fearAndGreedIndexEmoji = resolveFearAndGreedIndex(
this.fearAndGreedIndex
).emoji;
this.fearAndGreedIndexText = resolveFearAndGreedIndex(
this.fearAndGreedIndex
).text;
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FearAndGreedIndexComponent } from './fear-and-greed-index.component';
@NgModule({
declarations: [FearAndGreedIndexComponent],
exports: [FearAndGreedIndexComponent],
imports: [CommonModule]
})
export class GfFearAndGreedIndexModule {}

View File

@@ -0,0 +1,203 @@
<mat-toolbar class="p-0">
<ng-container *ngIf="user">
<a href="/" class="no-min-width px-2" mat-button>
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block"
href="/"
i18n
mat-flat-button
[color]="currentRoute === 'home' ? 'primary' : null"
>Overview</a
>
<a
class="d-none d-sm-block mx-1"
href="/analysis"
i18n
mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null"
>Analysis</a
>
<a
class="d-none d-sm-block mx-1"
href="/report"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
>X-ray</a
>
<a
class="d-none d-sm-block mx-1"
href="/transactions"
i18n
mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null"
>Transactions</a
>
<a
*ngIf="canAccessAdminAccessControl"
class="d-none d-sm-block mx-1"
href="/admin"
i18n
mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null"
>Admin Control</a
>
<a
class="d-none d-sm-block mx-1"
href="/resources"
i18n
mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null"
>Resources</a
>
<a
class="d-none d-sm-block mx-1"
href="/about"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
>About</a
>
<button
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
>
<ion-icon
class="d-none d-sm-block"
name="person-circle-outline"
size="large"
></ion-icon>
<ion-icon
class="d-block d-sm-none"
name="menu-outline"
size="large"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button
class="align-items-center d-flex"
mat-menu-item
(click)="impersonateAccount(null)"
>
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
disabled="false"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</button>
<hr class="m-0" />
</ng-container>
<a
class="d-block d-sm-none"
href="/analysis"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
>Analysis</a
>
<a
class="d-block d-sm-none"
href="/report"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
>X-ray</a
>
<a
class="d-block d-sm-none"
href="/transactions"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
>Transactions</a
>
<a
class="align-items-center d-flex"
href="/account"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
><span>Account</span
><ion-icon class="ml-1 text-muted" name="diamond-outline"></ion-icon
></a>
<a
*ngIf="canAccessAdminAccessControl"
class="d-block d-sm-none"
href="/admin"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-block d-sm-none"
href="/resources"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }"
>Resources</a
>
<a
class="d-block d-sm-none"
href="/about"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
>About Ghostfolio</a
>
<hr class="d-block d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu>
</ng-container>
<ng-container *ngIf="user === null">
<a
*ngIf="currentRoute && currentRoute !== 'start'"
href="/"
class="mx-2 no-min-width px-2"
mat-button
>
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block mx-1"
href="/about"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
>About</a
>
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
</ng-container>
</mat-toolbar>

View File

@@ -0,0 +1,38 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
z-index: 999;
.mat-toolbar {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-alpha)
);
.spacer {
flex: 1 1 auto;
}
.mat-flat-button {
&:not(.mat-primary) {
background-color: transparent;
}
ion-icon {
font-size: 1.5rem;
}
}
}
}
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: rgba(
39,
39,
39,
var(--palette-foreground-disabled-alpha)
);
}
}

View File

@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { User } from 'apps/api/src/app/user/interfaces/user.interface';
import { hasPermission, permissions } from 'libs/helper/src';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { LoginWithAccessTokenDialog } from '../../pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '../../services/data.service';
import { ImpersonationStorageService } from '../../services/impersonation-storage.service';
import { TokenStorageService } from '../../services/token-storage.service';
@Component({
selector: 'gf-header',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnChanges {
@Input() currentRoute: string;
@Input() user: User;
public canAccessAdminAccessControl: boolean;
public impersonationId: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dataService: DataService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private tokenStorageService: TokenStorageService
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.subscribe((id) => {
this.impersonationId = id;
});
}
public ngOnChanges() {
if (this.user) {
this.canAccessAdminAccessControl = hasPermission(
this.user.permissions,
permissions.accessAdminControl
);
}
}
public impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
} else {
this.impersonationStorageService.removeId();
}
window.location.reload();
}
public onSignOut() {
this.tokenStorageService.signOut();
window.location.reload();
}
public openLoginDialog(): void {
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
autoFocus: false,
data: { accessToken: '' },
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.router.navigate(['/']);
}
}

View File

@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { LoginWithAccessTokenDialogModule } from '../../pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '../logo/logo.module';
import { HeaderComponent } from './header.component';
@NgModule({
declarations: [HeaderComponent],
exports: [HeaderComponent],
imports: [
CommonModule,
GfLogoModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,
MatMenuModule,
MatToolbarModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHeaderModule {}

View File

@@ -0,0 +1,13 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '12rem',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
height="50"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,129 @@
import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioItem } from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
import {
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale
} from 'chart.js';
import { Chart } from 'chart.js';
import { primaryColorRgb } from 'libs/helper/src';
@Component({
selector: 'gf-investment-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './investment-chart.component.html',
styleUrls: ['./investment-chart.component.scss']
})
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() portfolioItems: PortfolioItem[];
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public isLoading = true;
public constructor() {
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale
);
}
public ngOnInit() {}
public ngOnChanges() {
if (this.portfolioItems) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
const data = {
labels: this.portfolioItems.map((position) => {
return position.date;
}),
datasets: [
{
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.portfolioItems.map((position) => {
return position.investment;
})
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
elements: {
line: {
tension: 0
},
point: {
radius: 0
}
},
maintainAspectRatio: true,
plugins: {
legend: {
display: false
}
},
responsive: true,
scales: {
x: {
display: false,
grid: {
display: false
},
type: 'time',
time: {
unit: 'year'
}
},
y: {
display: false,
grid: {
display: false
},
ticks: {
display: false
}
}
}
},
type: 'line'
});
this.isLoading = false;
}
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { InvestmentChartComponent } from './investment-chart.component';
@NgModule({
declarations: [InvestmentChartComponent],
exports: [InvestmentChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfInvestmentChartModule {}

View File

@@ -0,0 +1,4 @@
export interface LineChartItem {
date: string;
value: number;
}

View File

@@ -0,0 +1,13 @@
<ngx-skeleton-loader
*ngIf="isLoading && showLoader"
animation="pulse"
[theme]="{
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@@ -0,0 +1,7 @@
:host {
display: block;
ngx-skeleton-loader {
height: 100%;
}
}

View File

@@ -0,0 +1,188 @@
import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import {
Chart,
Filler,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale
} from 'chart.js';
import { primaryColorRgb, secondaryColorRgb } from 'libs/helper/src';
import { LineChartItem } from './interfaces/line-chart.interface';
@Component({
selector: 'gf-line-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './line-chart.component.html',
styleUrls: ['./line-chart.component.scss']
})
export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmarkLabel = '';
@Input() historicalDataItems: LineChartItem[];
@Input() showLegend: boolean;
@Input() showLoader = true;
@Input() showXAxis: boolean;
@Input() showYAxis: boolean;
@Input() symbol: string;
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public isLoading = true;
public constructor() {
Chart.register(
Filler,
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale
);
}
public ngOnInit() {}
public ngOnChanges() {
if (this.historicalDataItems) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
const benchmarkPrices = [];
const labels = [];
const marketPrices = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value);
labels.push(historicalDataItem.date);
marketPrices.push(historicalDataItem.value);
});
const canvas = document.getElementById('chartCanvas');
var gradient = this.chartCanvas?.nativeElement
?.getContext('2d')
.createLinearGradient(
0,
0,
0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
);
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(
1,
getComputedStyle(document.documentElement).getPropertyValue(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
)
);
const data = {
labels,
datasets: [
{
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 1,
data: benchmarkPrices,
fill: false,
label: this.benchmarkLabel,
pointRadius: 0
},
{
backgroundColor: gradient,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: marketPrices,
fill: true,
label: this.symbol,
pointRadius: 0
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
plugins: {
legend: {
align: 'start',
display: this.showLegend,
position: 'bottom'
}
},
scales: {
x: {
display: this.showXAxis,
grid: {
display: false
},
time: {
unit: 'year'
},
type: 'time'
},
y: {
display: this.showYAxis,
grid: {
display: false
},
ticks: {
display: this.showYAxis,
callback: function (tickValue, index, ticks) {
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
},
mirror: true,
z: 1
},
type: 'linear'
}
},
spanGaps: true
},
type: 'line'
});
}
}
this.isLoading = false;
}
public ngOnDestroy() {
this.chart?.destroy();
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { LineChartComponent } from './line-chart.component';
@NgModule({
declarations: [LineChartComponent],
exports: [LineChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfLineChartModule {}

View File

@@ -0,0 +1,4 @@
<span class="align-items-center d-flex"
><span class="d-inline-block logo mr-1"></span>
<span class="name">Ghostfolio</span></span
>

View File

@@ -0,0 +1,38 @@
:host {
.logo {
background-color: rgba(var(--dark-primary-text));
mask: url(~apps/client/src/assets/ghost.svg) no-repeat center;
}
.name {
font-weight: 500;
}
}
:host-context(.is-dark-theme) {
.logo {
background-color: rgba(var(--light-primary-text));
}
}
:host-context(.large) {
.logo {
height: 2.5rem;
width: 2.5rem;
}
.name {
font-size: 3rem;
}
}
:host-context(.medium) {
.logo {
height: 1.5rem;
width: 1.5rem;
}
.name {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
Input,
OnInit
} from '@angular/core';
@Component({
selector: 'gf-logo',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './logo.component.html',
styleUrls: ['./logo.component.scss']
})
export class LogoComponent implements OnInit {
@HostBinding('class') @Input() size: 'large' | 'medium';
public constructor() {}
public ngOnInit() {
this.size = this.size || 'medium';
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { LogoComponent } from './logo.component';
@NgModule({
declarations: [LogoComponent],
exports: [LogoComponent],
imports: [CommonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfLogoModule {}

View File

@@ -0,0 +1,9 @@
<a
class="align-items-center justify-content-center"
color="primary"
href="/transactions"
mat-button
>
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
<span i18n>Time to add your first transaction.</span>
</a>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
@Component({
selector: 'gf-no-transactions-info-indicator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './no-transactions-info.component.html',
styleUrls: ['./no-transactions-info.component.scss']
})
export class NoTransactionsInfoComponent implements OnInit {
public constructor() {}
public ngOnInit() {}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
@NgModule({
declarations: [NoTransactionsInfoComponent],
exports: [NoTransactionsInfoComponent],
imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfNoTransactionsInfoModule {}

View File

@@ -0,0 +1,7 @@
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
export interface PositionDetailDialogParams {
deviceType: string;
fearAndGreedIndex: number;
historicalDataItems: LineChartItem[];
}

View File

@@ -0,0 +1,12 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16 / 9;
margin: 0 -1rem;
}
}
}

View File

@@ -0,0 +1,87 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { isToday, parse } from 'date-fns';
import { DataService } from '../../services/data.service';
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@Component({
selector: 'gf-performance-chart-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'performance-chart-dialog.html',
styleUrls: ['./performance-chart-dialog.component.scss']
})
export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = 'S&P 500';
public benchmarkSymbol = 'VOO';
public currency: string;
public firstBuyDate: string;
public marketPrice: number;
public historicalDataItems: LineChartItem[];
public title: string;
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = [];
this.marketPrice = marketPrice;
let coefficient = 1;
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
if (benchmarkItem) {
if (coefficient === 1) {
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
}
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: benchmarkItem.value * coefficient
});
} else if (
isToday(parse(historicalDataItem.date, 'yyyy-MM-dd', new Date()))
) {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: marketPrice * coefficient
});
} else {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: undefined
});
}
});
this.cd.markForCheck();
});
this.title = `Performance vs. ${this.benchmarkLabel}`;
}
public onClose(): void {
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,34 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content>
<div class="container p-0">
<gf-line-chart
class="mb-4"
symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems"
[benchmarkLabel]="benchmarkLabel"
[historicalDataItems]="historicalDataItems"
[showLegend]="true"
[showXAxis]="true"
[showYAxis]="false"
></gf-line-chart>
</div>
<div class="container p-0">
<gf-fear-and-greed-index
class="d-flex flex-column justify-content-center"
[fearAndGreedIndex]="data.fearAndGreedIndex"
></gf-fear-and-greed-index>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfLineChartModule } from '../../components/line-chart/line-chart.module';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module';
import { GfValueModule } from '../value/value.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({
declarations: [PerformanceChartDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfFearAndGreedIndexModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: []
})
export class GfPerformanceChartDialogModule {}

View File

@@ -0,0 +1,60 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalBuy"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<span
*ngIf="overview?.totalSell || overview?.totalSell === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalSell"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>Investment</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.committedFunds"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ overview?.ordersCount }} {overview?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.fees"
></gf-value>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,28 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { Currency } from '@prisma/client';
import { PortfolioOverview } from 'apps/api/src/app/portfolio/interfaces/portfolio-overview.interface';
@Component({
selector: 'gf-portfolio-overview',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-overview.component.html',
styleUrls: ['./portfolio-overview.component.scss']
})
export class PortfolioOverviewComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() overview: PortfolioOverview;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { GfValueModule } from '../value/value.module';
import { PortfolioOverviewComponent } from './portfolio-overview.component';
@NgModule({
declarations: [PortfolioOverviewComponent],
exports: [PortfolioOverviewComponent],
imports: [CommonModule, GfValueModule],
providers: []
})
export class GfPortfolioOverviewModule {}

View File

@@ -0,0 +1,54 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="flex-grow-1"></div>
<div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader
animation="pulse"
class="mb-2"
[theme]="{
height: '4rem',
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<div
[hidden]="isLoading"
class="display-4 font-weight-bold m-0 text-center value-container"
>
<span #value id="value"></span>
</div>
<div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }}
</div>
</div>
</div>
<div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end">
<gf-value
colorizeSign="true"
isCurrency="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
</div>
<div class="col">
<gf-value
colorizeSign="true"
isPercent="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
</div>

View File

@@ -0,0 +1,9 @@
:host {
display: block;
.value-container {
#value {
font-variant-numeric: tabular-nums;
}
}
}

View File

@@ -0,0 +1,65 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnChanges,
OnInit,
ViewChild
} from '@angular/core';
import { Currency } from '@prisma/client';
import { PortfolioPerformance } from 'apps/api/src/app/portfolio/interfaces/portfolio-performance.interface';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@Component({
selector: 'gf-portfolio-performance-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance-summary.component.html',
styleUrls: ['./portfolio-performance-summary.component.scss']
})
export class PortfolioPerformanceSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;
@Input() showDetails: boolean;
@ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.isLoading) {
if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2,
duration: 1,
separator: `'`
}).start();
} else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
{
decimalPlaces: 2,
duration: 0.75,
separator: `'`
}
).start();
}
}
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceSummaryComponent } from './portfolio-performance-summary.component';
@NgModule({
declarations: [PortfolioPerformanceSummaryComponent],
exports: [PortfolioPerformanceSummaryComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfPortfolioPerformanceSummaryModule {}

View File

@@ -0,0 +1,60 @@
<div class="container p-0">
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Value</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentValue"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Gross performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
colorizeSign="true"
position="end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
></gf-value>
<gf-value
class="justify-content-end"
colorizeSign="true"
isPercent="true"
position="end"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentGrossPerformancePercent
"
></gf-value>
</div>
</div>
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Net performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
colorizeSign="true"
position="end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
<gf-value
class="justify-content-end"
colorizeSign="true"
isPercent="true"
position="end"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,25 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit
} from '@angular/core';
import { Currency } from '@prisma/client';
import { PortfolioPerformance } from 'apps/api/src/app/portfolio/interfaces/portfolio-performance.interface';
@Component({
selector: 'gf-portfolio-performance',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance.component.html',
styleUrls: ['./portfolio-performance.component.scss']
})
export class PortfolioPerformanceComponent implements OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;
public constructor() {}
public ngOnInit() {}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceComponent } from './portfolio-performance.component';
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule],
providers: []
})
export class GfPortfolioPerformanceModule {}

View File

@@ -0,0 +1,12 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '30rem',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#timelineCanvas
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,161 @@
// import 'chartjs-chart-timeline';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioItem } from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
import { Chart } from 'chart.js';
import { endOfDay, parseISO, startOfDay } from 'date-fns';
import { primaryColorRgb } from 'libs/helper/src';
@Component({
selector: 'gf-portfolio-positions-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-positions-chart.component.html',
styleUrls: ['./portfolio-positions-chart.component.scss']
})
export class PortfolioPositionsChartComponent implements OnChanges, OnInit {
@Input() portfolioItems: PortfolioItem[];
// @ViewChild('timelineCanvas') timeline;
public isLoading = true;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.portfolioItems) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
let datasets = [];
const fromToPosition = {};
this.portfolioItems.forEach((positionsByDay) => {
Object.keys(positionsByDay.positions).forEach((symbol) => {
if (fromToPosition[symbol]) {
fromToPosition[symbol].push({
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
});
} else {
fromToPosition[symbol] = [
{
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
}
];
}
});
});
Object.keys(fromToPosition).forEach((symbol) => {
let currentDate = null;
let currentQuantity = null;
let data = [];
let hasStock = false;
fromToPosition[symbol].forEach((x, index) => {
if (x.quantity > 0 && index === 0) {
currentDate = x.date;
hasStock = true;
}
if (x.quantity === 0 || index === fromToPosition[symbol].length - 1) {
if (hasStock) {
data.push([
startOfDay(parseISO(currentDate)),
endOfDay(parseISO(x.date)),
currentQuantity
]);
hasStock = false;
} else {
// Do nothing
}
} else {
if (hasStock) {
// Do nothing
} else {
currentDate = x.date;
hasStock = true;
}
}
currentQuantity = x.quantity;
});
if (data.length === 0) {
// Fill data for today
data.push([
startOfDay(new Date()),
endOfDay(new Date()),
currentQuantity
]);
}
datasets.push({ data, symbol });
});
// Sort by date
datasets = datasets.sort((a: any, b: any) => {
return a.data[0][0].getTime() - b.data[0][0].getTime();
});
/*new Chart(this.timeline.nativeElement, {
type: 'timeline',
options: {
elements: {
colorFunction: (text, data, dataset, index) => {
return `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`;
},
showText: false,
textPadding: 4
},
maintainAspectRatio: true,
responsive: true,
scales: {
xAxes: [
{
gridLines: {
display: false
},
position: 'top',
time: {
unit: 'year'
}
}
],
yAxes: [
{
gridLines: {
display: false
},
ticks: {
display: false
}
}
]
}
},
data: {
datasets,
labels: datasets.map((dataset) => {
return dataset.symbol;
})
}
});*/
this.isLoading = false;
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PortfolioPositionsChartComponent } from './portfolio-positions-chart.component';
@NgModule({
declarations: [PortfolioPositionsChartComponent],
exports: [PortfolioPositionsChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class PortfolioPositionsChartModule {}

View File

@@ -0,0 +1,12 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '15rem',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,160 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { Currency } from '@prisma/client';
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js';
@Component({
selector: 'gf-portfolio-proportion-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-proportion-chart.component.html',
styleUrls: ['./portfolio-proportion-chart.component.scss']
})
export class PortfolioProportionChartComponent
implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: Currency;
@Input() isInPercent: boolean;
@Input() key: string;
@Input() locale: string;
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
};
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public isLoading = true;
public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
}
public ngOnInit() {}
public ngOnChanges() {
if (this.positions) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
const chartData: { [symbol: string]: number } = {};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]] += this.positions[
symbol
].value;
} else {
chartData[this.positions[symbol][this.key]] = this.positions[
symbol
].value;
}
}
});
const chartDataSorted = Object.entries(chartData)
.sort((a, b) => {
return a[1] - b[1];
})
.reverse();
const data = {
datasets: [
{
backgroundColor: this.getColorPalette(),
borderWidth: 0,
data: chartDataSorted.map(([, value]) => {
return value;
})
}
],
labels: chartDataSorted.map(([label]) => {
return label;
})
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => {
const label = data.labels[context.dataIndex];
if (this.isInPercent) {
const value =
100 *
data.datasets[context.datasetIndex].data[
context.dataIndex
];
return `${label} (${value.toFixed(2)}%)`;
} else {
const value =
data.datasets[context.datasetIndex].data[
context.dataIndex
];
return `${label} (${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency})`;
}
}
}
}
}
},
type: 'doughnut'
});
}
this.isLoading = false;
}
}
/**
* Color palette, inspired by https://yeun.github.io/open-color
*/
private getColorPalette() {
//
return [
'#329af0', // blue 5
'#20c997', // teal 5
'#94d82d', // lime 5
'#ff922b', // orange 5
'#f06595', // pink 5
'#845ef7', // violet 5
'#5c7cfa', // indigo 5
'#22b8cf', // cyan 5
'#51cf66', // green 5
'#fcc419', // yellow 5
'#ff6b6b', // red 5
'#cc5de8' // grape 5
];
}
public ngOnDestroy() {
this.chart?.destroy();
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PortfolioProportionChartComponent } from './portfolio-proportion-chart.component';
@NgModule({
declarations: [PortfolioProportionChartComponent],
exports: [PortfolioProportionChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class PortfolioProportionChartModule {}

View File

@@ -0,0 +1,7 @@
export interface PositionDetailDialogParams {
baseCurrency: string;
deviceType: string;
locale: string;
symbol: string;
title: string;
}

View File

@@ -0,0 +1,12 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16 / 9;
margin: 0 -0.5rem;
}
}
}

View File

@@ -0,0 +1,132 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { DataService } from '../../../services/data.service';
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'position-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog {
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
public firstBuyDate: string;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[];
public investment: number;
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public quantity: number;
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
this.dataService
.fetchPositionDetail(data.symbol)
.subscribe(
({
averagePrice,
currency,
firstBuyDate,
grossPerformancePercent,
historicalData,
investment,
marketPrice,
maxPrice,
minPrice,
quantity
}) => {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map(
(historicalDataItem) => {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: historicalDataItem.averagePrice
});
return {
date: historicalDataItem.date,
value: historicalDataItem.value
};
}
);
this.investment = investment;
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.quantity = quantity;
if (isToday(parseISO(this.firstBuyDate))) {
// Add average price
this.historicalDataItems.push({
date: this.firstBuyDate,
value: this.averagePrice
});
// Add benchmark 1
this.benchmarkDataItems.push({
date: this.firstBuyDate,
value: averagePrice
});
// Add market price
this.historicalDataItems.push({
date: new Date().toISOString(),
value: this.marketPrice
});
// Add benchmark 2
this.benchmarkDataItems.push({
date: new Date().toISOString(),
value: averagePrice
});
} else {
// Add market price
this.historicalDataItems.push({
date: format(new Date(), 'yyyy-MM-dd'),
value: this.marketPrice
});
// Add benchmark
this.benchmarkDataItems.push({
date: format(new Date(), 'yyyy-MM-dd'),
value: averagePrice
});
}
if (
this.benchmarkDataItems[0]?.value === undefined &&
isSameMonth(parseISO(this.firstBuyDate), new Date())
) {
this.benchmarkDataItems[0].value = this.averagePrice;
}
this.cd.markForCheck();
}
);
}
public onClose(): void {
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,101 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="data.title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
<gf-line-chart
class="mb-4"
benchmarkLabel="Buy Price"
[benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="data.symbol"
></gf-line-chart>
<div class="row">
<div class="col-6 mb-3">
<gf-value
colorizeSign="true"
isPercent="true"
label="Performance"
size="medium"
[locale]="data.locale"
[value]="grossPerformancePercent"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="First Buy Date"
size="medium"
[value]="firstBuyDate"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Ø Buy Price"
size="medium"
[currency]="currency"
[locale]="data.locale"
[value]="averagePrice"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Market Price"
size="medium"
[currency]="currency"
[locale]="data.locale"
[value]="marketPrice"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Minimum Price"
size="medium"
[currency]="currency"
[locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="minPrice"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Maximum Price"
size="medium"
[currency]="currency"
[locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[value]="maxPrice"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
isCurrency="true"
label="Quantity"
size="medium"
[value]="quantity"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Investment"
size="medium"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="investment"
></gf-value>
</div>
</div>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfLineChartModule } from '../../../components/line-chart/line-chart.module';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GfValueModule } from '../../value/value.module';
import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({
declarations: [PositionDetailDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PositionDetailDialogModule {}

View File

@@ -0,0 +1,71 @@
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex p-3 w-100"
[routerLink]="[]"
[queryParams]="{ positionDetailDialog: true, symbol: position?.symbol }"
>
<div class="d-flex mr-2">
<gf-trend-indicator
class="d-flex"
[isLoading]="isLoading"
[isPaused]="!position?.isMarketOpen"
[range]="range"
[value]="position?.grossPerformancePercent"
></gf-trend-indicator>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
class="mb-1"
[theme]="{
height: '1.2rem',
width: '12rem'
}"
></ngx-skeleton-loader>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '8rem'
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex">
<span>{{ position?.symbol }}</span>
<gf-symbol-icon
*ngIf="position?.url"
class="ml-1"
[url]="position?.url"
></gf-symbol-icon>
<span class="ml-2 text-muted">({{ position?.exchange }})</span>
</div>
<div class="d-flex mt-1">
<gf-value
class="mr-3"
colorizeSign="true"
[currency]="position?.currency"
[locale]="locale"
[value]="position?.grossPerformance"
></gf-value>
<gf-value
colorizeSign="true"
isPercent="true"
[locale]="locale"
[value]="position?.grossPerformancePercent"
></gf-value>
</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
*ngIf="!isLoading"
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>

View File

@@ -0,0 +1,15 @@
:host {
display: block;
.container {
cursor: pointer;
gf-trend-indicator {
padding-top: 0.15rem;
}
.chevron {
opacity: 0.33;
}
}
}

View File

@@ -0,0 +1,77 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@Component({
selector: 'gf-position',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './position.component.html',
styleUrls: ['./position.component.scss']
})
export class PositionComponent implements OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() position: PortfolioPosition;
@Input() range: string;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['symbol'] === this.position?.symbol
) {
this.openDialog();
}
});
}
public ngOnInit() {}
private openDialog(): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol,
title: this.position?.name
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfTrendIndicatorModule } from '../trend-indicator/trend-indicator.module';
import { GfValueModule } from '../value/value.module';
import { PositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module';
import { PositionComponent } from './position.component';
@NgModule({
declarations: [PositionComponent],
exports: [PositionComponent],
imports: [
CommonModule,
GfSymbolIconModule,
GfTrendIndicatorModule,
GfValueModule,
MatDialogModule,
NgxSkeletonLoaderModule,
PositionDetailDialogModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionModule {}

View File

@@ -0,0 +1,113 @@
<!--<mat-form-field appearance="outline" class="w-100">
<input #input autocomplete="off" matInput (keyup)="applyFilter($event)" />
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
</mat-form-field>-->
<table
class="w-100"
matSort
matSortActive="shareCurrent"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Symbol</th>
<td mat-cell *matCellDef="let element">{{ element.symbol }}</td>
</ng-container>
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell text-right"
i18n
mat-header-cell
>
Performance
</th>
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
colorizeSign="true"
isPercent="true"
[locale]="locale"
[value]="isLoading ? undefined : element.grossPerformancePercent"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="shareInvestment">
<th
*matHeaderCellDef
class="justify-content-end"
i18n
mat-header-cell
mat-sort-header
>
Initial Share
</th>
<td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
isPercent="true"
[locale]="locale"
[value]="isLoading ? undefined : element.shareInvestment"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="shareCurrent">
<th
*matHeaderCellDef
class="justify-content-end"
i18n
mat-header-cell
mat-sort-header
>
Current Share
</th>
<td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
isPercent="true"
[locale]="locale"
[value]="isLoading ? undefined : element.shareCurrent"
></gf-value>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: row.name })"
></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
<div
*ngIf="dataSource.data.length > pageSize && !isLoading"
class="my-3 text-center"
>
<button i18n mat-stroked-button (click)="onShowAllPositions()">
Show all positions...
</button>
</div>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

View File

@@ -0,0 +1,51 @@
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-table {
td {
border: 0;
}
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
.mat-row {
cursor: pointer;
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
.mat-table {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}

View File

@@ -0,0 +1,145 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Order as OrderModel } from '@prisma/client';
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
@Component({
selector: 'gf-positions-table',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './positions-table.component.html',
styleUrls: ['./positions-table.component.scss']
})
export class PositionsTableComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@Input() positions: PortfolioPosition[];
@Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToUpdate = new EventEmitter<OrderModel>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<PortfolioPosition> = new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['title']
) {
this.openPositionDialog({
symbol: params['symbol'],
title: params['title']
});
}
});
}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = [
'symbol',
'performance',
'shareInvestment',
'shareCurrent'
];
this.isLoading = true;
if (this.positions) {
this.dataSource = new MatTableDataSource(this.positions);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.isLoading = false;
}
}
/*public applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title }
});
}
public onShowAllPositions() {
this.pageSize = Number.MAX_SAFE_INTEGER;
setTimeout(() => {
this.dataSource.paginator = this.paginator;
});
}
public openPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
title,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@@ -0,0 +1,39 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { PositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { PositionsTableComponent } from './positions-table.component';
@NgModule({
declarations: [PositionsTableComponent],
exports: [PositionsTableComponent],
imports: [
CommonModule,
GfNoTransactionsInfoModule,
GfSymbolIconModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
MatInputModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
PositionDetailDialogModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsTableModule {}

View File

@@ -0,0 +1,32 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
<ng-container *ngIf="positions === undefined">
<gf-position [isLoading]="true"></gf-position>
</ng-container>
<ng-container *ngIf="positions !== undefined">
<ng-container *ngIf="hasPositions">
<gf-position
*ngFor="let position of positionsWithPriority"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
></gf-position>
<gf-position
*ngFor="let position of positionsRest"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
></gf-position>
</ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</ng-container>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}

View File

@@ -0,0 +1,67 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
@Component({
selector: 'gf-positions',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './positions.component.html',
styleUrls: ['./positions.component.scss']
})
export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@Input() positions: { [symbol: string]: PortfolioPosition };
@Input() range: string;
public hasPositions: boolean;
public positionsRest: PortfolioPosition[] = [];
public positionsWithPriority: PortfolioPosition[] = [];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.positions) {
this.hasPositions = Object.entries(this.positions).length > 0;
if (!this.hasPositions) {
return;
}
this.positionsRest = [];
this.positionsWithPriority = [];
for (const [, portfolioPosition] of Object.entries(this.positions)) {
if (portfolioPosition.isMarketOpen || this.range !== '1d') {
// Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition);
} else {
this.positionsRest.push(portfolioPosition);
}
}
this.positionsRest.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
this.positionsWithPriority.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
} else {
this.hasPositions = false;
}
}
}

View File

@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module';
import { PositionsComponent } from './positions.component';
@NgModule({
declarations: [PositionsComponent],
exports: [PositionsComponent],
imports: [
CommonModule,
GfNoTransactionsInfoModule,
GfPositionModule,
MatButtonModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsModule {}

View File

@@ -0,0 +1,46 @@
<div class="py-3">
<div class="flex-nowrap no-gutters row">
<div *ngIf="isLoading">
<ngx-skeleton-loader
animation="pulse"
class="mr-3"
[theme]="{
height: '3rem',
width: '3rem'
}"
></ngx-skeleton-loader>
</div>
<div
*ngIf="!isLoading"
class="align-items-center d-flex icon-container mr-3 px-3"
[ngClass]="{ okay: rule?.value === true, warn: rule?.value === false }"
>
<ion-icon
*ngIf="rule?.value === true"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon *ngIf="rule?.value === false" name="warning-outline"></ion-icon>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
class="mt-1 mb-1"
[theme]="{
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading" class="flex-grow-1">
<div class="h6 my-1">{{ rule?.name }}</div>
<div class="evaluation">{{ rule?.evaluation }}</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
:host {
display: block;
.icon-container {
border-radius: 0.25rem;
height: 3rem;
&.okay {
background-color: var(--success);
}
&.warn {
background-color: var(--warning);
}
}
.evaluation {
line-height: 1.2;
}
}
:host-context(.is-dark-theme) {
.icon-container {
color: rgba(var(--dark-primary-text));
}
}

View File

@@ -0,0 +1,22 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit
} from '@angular/core';
import { PortfolioReportRule } from 'apps/api/src/app/portfolio/interfaces/portfolio-report.interface';
@Component({
selector: 'gf-rule',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './rule.component.html',
styleUrls: ['./rule.component.scss']
})
export class RuleComponent implements OnInit {
@Input() isLoading: boolean;
@Input() rule: PortfolioReportRule;
public constructor() {}
public ngOnInit() {}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { RuleComponent } from './rule.component';
@NgModule({
declarations: [RuleComponent],
exports: [RuleComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfRuleModule {}

View File

@@ -0,0 +1,14 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</mat-card>
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>
<ng-container *ngIf="rules !== null && rules !== undefined">
<gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule>
</ng-container>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { PortfolioReportRule } from 'apps/api/src/app/portfolio/interfaces/portfolio-report.interface';
@Component({
selector: 'gf-rules',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './rules.component.html',
styleUrls: ['./rules.component.scss']
})
export class RulesComponent {
@Input() rules: PortfolioReportRule;
public constructor() {}
}

View File

@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfRuleModule } from 'apps/client/src/app/components/rule/rule.module';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module';
import { RulesComponent } from './rules.component';
@NgModule({
declarations: [RulesComponent],
exports: [RulesComponent],
imports: [
CommonModule,
GfNoTransactionsInfoModule,
GfPositionModule,
GfRuleModule,
MatButtonModule,
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class RulesModule {}

View File

@@ -0,0 +1,5 @@
<img
*ngIf="url"
src="https://www.google.com/s2/favicons?domain={{ url }}&sz=64"
[title]="tooltip ? tooltip : ''"
/>

View File

@@ -0,0 +1,9 @@
:host {
display: block;
img {
border-radius: 0.2rem;
height: 0.8rem;
width: 0.8rem;
}
}

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