Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
91ec9aa0a4 | |||
565e920f1b | |||
5d24adfa75 | |||
1dc94c0027 | |||
ebae2f4ec9 | |||
7099edc591 | |||
de973d6bda | |||
993a491d24 | |||
631efff7ae | |||
a3d1ac2ce4 | |||
4484c21757 | |||
87cd3ef33f | |||
163f4a3d3f | |||
a84256dc03 | |||
cf82066976 |
9
.travis.yml
Normal file
9
.travis.yml
Normal file
@ -0,0 +1,9 @@
|
||||
language: node_js
|
||||
git:
|
||||
depth: false
|
||||
node_js:
|
||||
- 14
|
||||
before_script:
|
||||
- yarn
|
||||
script:
|
||||
- yarn format:check
|
36
CHANGELOG.md
36
CHANGELOG.md
@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.1.0 - 11.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to fetch the current market price in the create or edit transaction dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the transaction filtering with multi filter support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the filtering by account name in the transactions table
|
||||
- Fixed the active menu item state when a modal has opened
|
||||
|
||||
## 1.0.0 - 05.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the functionality to clone a transaction
|
||||
- Added a _Google Play_ badge on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed to maskable icons
|
||||
|
||||
## 0.99.0 - 03.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for deleting users in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Eliminated the platform attribute from the transaction model
|
||||
|
||||
## 0.98.0 - 02.05.2021
|
||||
|
||||
### Added
|
||||
|
19
README.md
19
README.md
@ -68,19 +68,18 @@ The frontend is built with [Angular](https://angular.io).
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
2. Run `cd docker`
|
||||
3. Run `docker compose build`
|
||||
4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
5. Run `cd -` to go back to the project root directory
|
||||
6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
7. Start server and client (see _Development_)
|
||||
8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
||||
10. Press _Sign out_ and check out the _Live Demo_
|
||||
1. Run `cd docker`
|
||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `cd -` to go back to the project root directory
|
||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
||||
1. Press _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
|
||||
Please make sure you have completed the instructions from _Setup_
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup)
|
||||
|
||||
### Start server
|
||||
|
||||
|
@ -17,10 +17,6 @@ export class CreateOrderDto {
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Account, Order } from '@prisma/client';
|
||||
import { Account, Order, Platform } from '@prisma/client';
|
||||
|
||||
export type OrderWithAccount = Order & { Account?: Account };
|
||||
type AccountWithPlatform = Account & { Platform?: Platform };
|
||||
|
||||
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
|
||||
|
@ -125,41 +125,19 @@ export class OrderController {
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ -196,60 +174,26 @@ export class OrderController {
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
// platformId is null, remove it
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
Platform: originalOrder.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -17,10 +17,6 @@ export class UpdateOrderDto {
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -15,6 +16,7 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
@ -30,6 +32,27 @@ export class UserController {
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteUser
|
||||
) ||
|
||||
id === this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.deleteUser({
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getUser(@Param('id') id: string): Promise<User> {
|
||||
|
@ -163,6 +163,28 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||
await this.prisma.access.deleteMany({
|
||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||
});
|
||||
|
||||
await this.prisma.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
await this.prisma.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
await this.prisma.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
try {
|
||||
await this.prisma.settings.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return this.prisma.user.delete({
|
||||
where
|
||||
});
|
||||
|
@ -149,7 +149,6 @@ describe('Portfolio', () => {
|
||||
fee: 0,
|
||||
date: new Date(),
|
||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||
platformId: null,
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
type: Type.BUY,
|
||||
@ -170,19 +169,7 @@ describe('Portfolio', () => {
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
BTCUSD: {
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
platforms: {
|
||||
accounts: {
|
||||
Other: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
@ -196,11 +183,23 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
quantity: 1,
|
||||
// shareCurrent: 0.9999999559148652,
|
||||
shareInvestment: 1,
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 0,
|
||||
transactionCount: 1,
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
@ -251,7 +250,6 @@ describe('Portfolio', () => {
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
@ -272,18 +270,7 @@ describe('Portfolio', () => {
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
ETHUSD: {
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
// grossPerformance: 0,
|
||||
// grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
name: 'Ethereum USD',
|
||||
platforms: {
|
||||
accounts: {
|
||||
Other: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
@ -297,6 +284,17 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
// grossPerformance: 0,
|
||||
// grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
name: 'Ethereum USD',
|
||||
quantity: 0.2,
|
||||
// shareCurrent: 1,
|
||||
shareInvestment: 1,
|
||||
@ -347,7 +345,6 @@ describe('Portfolio', () => {
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
@ -364,7 +361,6 @@ describe('Portfolio', () => {
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.3,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
@ -425,7 +421,6 @@ describe('Portfolio', () => {
|
||||
date: new Date(getUtc('2017-08-16')),
|
||||
fee: 2.99,
|
||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||
platformId: null,
|
||||
quantity: 0.05614682,
|
||||
symbol: 'BTCUSD',
|
||||
type: Type.BUY,
|
||||
@ -442,7 +437,6 @@ describe('Portfolio', () => {
|
||||
fee: 2.99,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
@ -516,7 +510,6 @@ describe('Portfolio', () => {
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
@ -533,7 +526,6 @@ describe('Portfolio', () => {
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.1,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.SELL,
|
||||
@ -550,7 +542,6 @@ describe('Portfolio', () => {
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-31')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
type: Type.BUY,
|
||||
|
@ -57,9 +57,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe((test) => {
|
||||
.subscribe(() => {
|
||||
this.currentRoute = this.router.url.toString().substring(1);
|
||||
// this.initializeTheme();
|
||||
});
|
||||
|
||||
this.tokenStorageService
|
||||
|
@ -9,7 +9,7 @@
|
||||
[routerLink]="['/']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'home' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('home') ? 'primary' : null"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
@ -17,7 +17,7 @@
|
||||
[routerLink]="['/analysis']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'analysis' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null"
|
||||
>Analysis</a
|
||||
>
|
||||
<a
|
||||
@ -25,7 +25,7 @@
|
||||
[routerLink]="['/report']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'report' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('report') ? 'primary' : null"
|
||||
>X-ray</a
|
||||
>
|
||||
<a
|
||||
@ -33,7 +33,7 @@
|
||||
[routerLink]="['/transactions']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'transactions' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
@ -41,7 +41,7 @@
|
||||
[routerLink]="['/accounts']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'accounts' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
@ -50,7 +50,7 @@
|
||||
[routerLink]="['/admin']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'admin' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('admin') ? 'primary' : null"
|
||||
>Admin Control</a
|
||||
>
|
||||
<a
|
||||
@ -58,7 +58,7 @@
|
||||
[routerLink]="['/resources']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'resources' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('resources') ? 'primary' : null"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
@ -66,7 +66,7 @@
|
||||
[routerLink]="['/about']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
|
||||
>About</a
|
||||
>
|
||||
<button
|
||||
@ -130,7 +130,7 @@
|
||||
[routerLink]="['/analysis']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }"
|
||||
>Analysis</a
|
||||
>
|
||||
<a
|
||||
@ -138,7 +138,7 @@
|
||||
[routerLink]="['/report']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
|
||||
>X-ray</a
|
||||
>
|
||||
<a
|
||||
@ -146,7 +146,9 @@
|
||||
[routerLink]="['/transactions']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute?.startsWith('transactions')
|
||||
}"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
@ -154,7 +156,7 @@
|
||||
[routerLink]="['/accounts']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
@ -162,7 +164,7 @@
|
||||
[routerLink]="['/account']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }"
|
||||
>Ghostfolio Account</a
|
||||
>
|
||||
<a
|
||||
@ -171,7 +173,7 @@
|
||||
[routerLink]="['/admin']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }"
|
||||
>Admin Control</a
|
||||
>
|
||||
<hr class="m-0" />
|
||||
@ -180,7 +182,9 @@
|
||||
[routerLink]="['/resources']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute?.startsWith('resources')
|
||||
}"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
@ -188,7 +192,7 @@
|
||||
[routerLink]="['/about']"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-block d-sm-none m-0" />
|
||||
@ -210,7 +214,7 @@
|
||||
[routerLink]="['/about']"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
||||
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
|
@ -1,12 +1,37 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<input
|
||||
#input
|
||||
autocomplete="off"
|
||||
matInput
|
||||
placeholder="Search for transactions..."
|
||||
(keyup)="applyFilter($event)"
|
||||
/>
|
||||
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
||||
<mat-chip-list #chipList aria-label="Search keywords">
|
||||
<mat-chip
|
||||
*ngFor="let searchKeyword of searchKeywords"
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
(removed)="removeKeyword(searchKeyword)"
|
||||
>
|
||||
{{ searchKeyword }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||
</mat-chip>
|
||||
<input
|
||||
#searchInput
|
||||
name="close-outline"
|
||||
placeholder="Search for account, currency, symbol or type..."
|
||||
[formControl]="searchControl"
|
||||
[matAutocomplete]="autocomplete"
|
||||
[matChipInputFor]="chipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
(matChipInputTokenEnd)="addKeyword($event)"
|
||||
/>
|
||||
</mat-chip-list>
|
||||
<mat-autocomplete
|
||||
#autocomplete="matAutocomplete"
|
||||
(optionSelected)="keywordSelected($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let transaction of filteredTransactions | async"
|
||||
[value]="transaction"
|
||||
>
|
||||
{{ transaction }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
<table
|
||||
@ -177,6 +202,9 @@
|
||||
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
|
||||
Edit
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onCloneTransaction(element)">
|
||||
Clone
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onDeleteTransaction(element.id)">
|
||||
Delete
|
||||
</button>
|
||||
|
@ -7,6 +7,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mat-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
border: 0;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
@ -13,12 +14,21 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { OrderWithAccount } from '@ghostfolio/api/app/order/interfaces/order-with-account.type';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
||||
import {
|
||||
MatAutocomplete,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { MatChipInputEvent } from '@angular/material/chips';
|
||||
|
||||
const SEARCH_STRING_SEPARATOR = ',';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-transactions-table',
|
||||
@ -32,19 +42,30 @@ export class TransactionsTableComponent
|
||||
@Input() deviceType: string;
|
||||
@Input() locale: string;
|
||||
@Input() showActions: boolean;
|
||||
@Input() transactions: OrderModel[];
|
||||
@Input() transactions: OrderWithAccount[];
|
||||
|
||||
@Output() transactionDeleted = new EventEmitter<string>();
|
||||
@Output() transactionToUpdate = new EventEmitter<OrderModel>();
|
||||
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
|
||||
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
|
||||
|
||||
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public dataSource: MatTableDataSource<OrderModel> = new MatTableDataSource();
|
||||
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public displayedColumns = [];
|
||||
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]);
|
||||
public filteredTransactions: Observable<
|
||||
string[]
|
||||
> = this.filteredTransactions$.asObservable();
|
||||
public isLoading = true;
|
||||
public routeQueryParams: Subscription;
|
||||
public searchControl = new FormControl();
|
||||
public searchKeywords: string[] = [];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
|
||||
private allFilteredTransactions: string[];
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
@ -62,6 +83,49 @@ export class TransactionsTableComponent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.searchControl.valueChanges.subscribe((keyword) => {
|
||||
if (keyword) {
|
||||
const filterValue = keyword.toLowerCase();
|
||||
this.filteredTransactions$.next(
|
||||
this.allFilteredTransactions.filter(
|
||||
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.filteredTransactions$.next(this.allFilteredTransactions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public addKeyword({ input, value }: MatChipInputEvent): void {
|
||||
if (value?.trim()) {
|
||||
this.searchKeywords.push(value.trim());
|
||||
this.updateFilter();
|
||||
}
|
||||
|
||||
// Reset the input value
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
this.searchControl.setValue(null);
|
||||
}
|
||||
|
||||
public removeKeyword(keyword: string): void {
|
||||
const index = this.searchKeywords.indexOf(keyword);
|
||||
|
||||
if (index >= 0) {
|
||||
this.searchKeywords.splice(index, 1);
|
||||
this.updateFilter();
|
||||
}
|
||||
}
|
||||
|
||||
public keywordSelected(event: MatAutocompleteSelectedEvent): void {
|
||||
this.searchKeywords.push(event.option.viewValue);
|
||||
this.updateFilter();
|
||||
this.searchInput.nativeElement.value = '';
|
||||
this.searchControl.setValue(null);
|
||||
}
|
||||
|
||||
public ngOnInit() {}
|
||||
@ -86,17 +150,23 @@ export class TransactionsTableComponent
|
||||
|
||||
if (this.transactions) {
|
||||
this.dataSource = new MatTableDataSource(this.transactions);
|
||||
this.dataSource.filterPredicate = (data, filter) => {
|
||||
const dataString = TransactionsTableComponent.getFilterableValues(data)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
let contains = true;
|
||||
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
|
||||
contains =
|
||||
contains && dataString.includes(singleFilter.trim().toLowerCase());
|
||||
}
|
||||
return contains;
|
||||
};
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.updateFilter();
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public applyFilter(event: Event) {
|
||||
const filterValue = (event.target as HTMLInputElement).value;
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
public onDeleteTransaction(aId: string) {
|
||||
const confirmation = confirm(
|
||||
'Do you really want to delete this transaction?'
|
||||
@ -119,10 +189,14 @@ export class TransactionsTableComponent
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateTransaction(aTransaction: OrderModel) {
|
||||
public onUpdateTransaction(aTransaction: OrderWithAccount) {
|
||||
this.transactionToUpdate.emit(aTransaction);
|
||||
}
|
||||
|
||||
public onCloneTransaction(aTransaction: OrderWithAccount) {
|
||||
this.transactionToClone.emit(aTransaction);
|
||||
}
|
||||
|
||||
public openPositionDialog({
|
||||
symbol,
|
||||
title
|
||||
@ -152,4 +226,40 @@ export class TransactionsTableComponent
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private updateFilter() {
|
||||
this.dataSource.filter = this.searchKeywords.join(SEARCH_STRING_SEPARATOR);
|
||||
const lowercaseSearchKeywords = this.searchKeywords.map((keyword) =>
|
||||
keyword.trim().toLowerCase()
|
||||
);
|
||||
this.allFilteredTransactions = TransactionsTableComponent.getSearchableFieldValues(
|
||||
this.transactions
|
||||
).filter((item) => {
|
||||
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
|
||||
});
|
||||
this.filteredTransactions$.next(this.allFilteredTransactions);
|
||||
}
|
||||
|
||||
private static getSearchableFieldValues(
|
||||
transactions: OrderWithAccount[]
|
||||
): string[] {
|
||||
const fieldValues = new Set<string>();
|
||||
for (const transaction of transactions) {
|
||||
this.getFilterableValues(transaction, fieldValues);
|
||||
}
|
||||
|
||||
return [...fieldValues].filter((item) => item != undefined).sort();
|
||||
}
|
||||
|
||||
private static getFilterableValues(
|
||||
transaction,
|
||||
fieldValues: Set<string> = new Set<string>()
|
||||
): string[] {
|
||||
fieldValues.add(transaction.currency);
|
||||
fieldValues.add(transaction.symbol);
|
||||
fieldValues.add(transaction.type);
|
||||
fieldValues.add(transaction.Account?.name);
|
||||
fieldValues.add(transaction.Account?.Platform?.name);
|
||||
return [...fieldValues].filter((item) => item != undefined);
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,9 @@ import { GfPositionDetailDialogModule } from '../position/position-detail-dialog
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { GfValueModule } from '../value/value.module';
|
||||
import { TransactionsTableComponent } from './transactions-table.component';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
@NgModule({
|
||||
declarations: [TransactionsTableComponent],
|
||||
@ -23,12 +26,15 @@ import { TransactionsTableComponent } from './transactions-table.component';
|
||||
GfSymbolIconModule,
|
||||
GfSymbolModule,
|
||||
GfValueModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
|
||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
|
||||
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -20,6 +22,7 @@ export class AdminPageComponent implements OnInit {
|
||||
public lastDataGathering: string;
|
||||
public transactionCount: number;
|
||||
public userCount: number;
|
||||
public user: User;
|
||||
public users: AdminData['users'];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -31,46 +34,24 @@ export class AdminPageComponent implements OnInit {
|
||||
private adminService: AdminService,
|
||||
private cacheService: CacheService,
|
||||
private cd: ChangeDetectorRef,
|
||||
private dataService: DataService
|
||||
private dataService: DataService,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.dataService
|
||||
.fetchAdminData()
|
||||
this.fetchAdminData();
|
||||
|
||||
this.tokenStorageService
|
||||
.onChangeHasToken()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
exchangeRates,
|
||||
lastDataGathering,
|
||||
transactionCount,
|
||||
userCount,
|
||||
users
|
||||
}) => {
|
||||
this.exchangeRates = exchangeRates;
|
||||
this.users = users;
|
||||
|
||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
||||
this.lastDataGathering = formatDistanceToNow(
|
||||
new Date(lastDataGathering),
|
||||
{
|
||||
addSuffix: true
|
||||
}
|
||||
);
|
||||
} else if (lastDataGathering === 'IN_PROGRESS') {
|
||||
this.dataGatheringInProgress = true;
|
||||
} else {
|
||||
this.lastDataGathering = '-';
|
||||
}
|
||||
|
||||
this.transactionCount = transactionCount;
|
||||
this.userCount = userCount;
|
||||
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
);
|
||||
.subscribe(() => {
|
||||
this.dataService.fetchUser().subscribe((user) => {
|
||||
this.user = user;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onFlushCache() {
|
||||
@ -112,8 +93,56 @@ export class AdminPageComponent implements OnInit {
|
||||
return '';
|
||||
}
|
||||
|
||||
public onDeleteUser(aId: string) {
|
||||
const confirmation = confirm('Do you really want to delete this user?');
|
||||
|
||||
if (confirmation) {
|
||||
this.dataService.deleteUser(aId).subscribe({
|
||||
next: () => {
|
||||
this.fetchAdminData();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private fetchAdminData() {
|
||||
this.dataService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
exchangeRates,
|
||||
lastDataGathering,
|
||||
transactionCount,
|
||||
userCount,
|
||||
users
|
||||
}) => {
|
||||
this.exchangeRates = exchangeRates;
|
||||
this.users = users;
|
||||
|
||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
||||
this.lastDataGathering = formatDistanceToNow(
|
||||
new Date(lastDataGathering),
|
||||
{
|
||||
addSuffix: true
|
||||
}
|
||||
);
|
||||
} else if (lastDataGathering === 'IN_PROGRESS') {
|
||||
this.dataGatheringInProgress = true;
|
||||
} else {
|
||||
this.lastDataGathering = '-';
|
||||
}
|
||||
|
||||
this.transactionCount = transactionCount;
|
||||
this.userCount = userCount;
|
||||
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -82,6 +82,7 @@
|
||||
<th class="mat-header-cell pr-2 py-2" i18n>Transactions</th>
|
||||
<th class="mat-header-cell pr-2 py-2" i18n>Engagement</th>
|
||||
<th class="mat-header-cell pr-3 py-2" i18n>Last Activitiy</th>
|
||||
<th class="mat-header-cell pr-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -102,6 +103,26 @@
|
||||
<td class="mat-cell pr-3 py-2">
|
||||
{{ formatDistanceToNow(userItem.Analytics?.updatedAt) }}
|
||||
</td>
|
||||
<td class="mat-cell pr-3 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
[disabled]="userItem.id === user?.id"
|
||||
(click)="onDeleteUser(userItem.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -2,6 +2,7 @@ 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 { MatMenuModule } from '@angular/material/menu';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
|
||||
import { AdminPageRoutingModule } from './admin-page-routing.module';
|
||||
@ -14,7 +15,8 @@ import { AdminPageComponent } from './admin-page.component';
|
||||
AdminPageRoutingModule,
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCardModule
|
||||
MatCardModule,
|
||||
MatMenuModule
|
||||
],
|
||||
providers: [CacheService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -156,4 +156,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="downloads my-5 row justify-content-center">
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||
title="Get Ghostfolio on Google Play"
|
||||
>
|
||||
<img alt="Google Play Badge" src="assets/badge-en-google-play.png" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,6 +18,12 @@
|
||||
background-color: var(--light-background);
|
||||
}
|
||||
}
|
||||
|
||||
.downloads {
|
||||
img {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
|
@ -30,6 +30,7 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
|
||||
})
|
||||
export class CreateOrUpdateTransactionDialog {
|
||||
public currencies: Currency[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public filteredLookupItems: Observable<LookupItem[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
@ -65,6 +66,20 @@ export class CreateOrUpdateTransactionDialog {
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
if (this.data.transaction.symbol) {
|
||||
this.dataService
|
||||
.fetchSymbolItem(this.data.transaction.symbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
this.currentMarketPrice = marketPrice;
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public applyCurrentMarketPrice() {
|
||||
this.data.transaction.unitPrice = this.currentMarketPrice;
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
@ -81,7 +96,7 @@ export class CreateOrUpdateTransactionDialog {
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.data.transaction.currency = currency;
|
||||
this.data.transaction.dataSource = dataSource;
|
||||
this.data.transaction.unitPrice = marketPrice;
|
||||
this.currentMarketPrice = marketPrice;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
|
@ -81,7 +81,13 @@
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.transaction.date"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
|
||||
<mat-datepicker-toggle matSuffix [for]="date">
|
||||
<ion-icon
|
||||
class="text-muted"
|
||||
matDatepickerToggleIcon
|
||||
name="calendar-clear-outline"
|
||||
></ion-icon>
|
||||
</mat-datepicker-toggle>
|
||||
<mat-datepicker #date disabled="false"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@ -110,7 +116,7 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field appearance="outline" class="unit-price w-100">
|
||||
<mat-label i18n>Unit Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
@ -119,6 +125,15 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.unitPrice"
|
||||
/>
|
||||
<button
|
||||
*ngIf="currentMarketPrice"
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Apply current market price"
|
||||
(click)="applyCurrentMarketPrice()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@ -30,6 +30,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class CreateOrUpdateTransactionDialogModule {}
|
||||
|
@ -14,6 +14,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mat-form-field-appearance-outline {
|
||||
::ng-deep {
|
||||
.mat-form-field-suffix {
|
||||
top: -0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-select {
|
||||
&.no-arrow {
|
||||
::ng-deep {
|
||||
|
@ -109,6 +109,10 @@ export class TransactionsPageComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onCloneTransaction(aTransaction: OrderModel) {
|
||||
this.openCreateTransactionDialog(aTransaction);
|
||||
}
|
||||
|
||||
public onDeleteTransaction(aId: string) {
|
||||
this.dataService.deleteOrder(aId).subscribe({
|
||||
next: () => {
|
||||
@ -130,7 +134,6 @@ export class TransactionsPageComponent implements OnInit {
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
platformId,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
@ -146,7 +149,6 @@ export class TransactionsPageComponent implements OnInit {
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
platformId,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
@ -177,21 +179,23 @@ export class TransactionsPageComponent implements OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openCreateTransactionDialog(): void {
|
||||
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
data: {
|
||||
accounts: this.user?.accounts,
|
||||
transaction: {
|
||||
accountId: this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id,
|
||||
currency: null,
|
||||
accountId:
|
||||
aTransaction?.accountId ??
|
||||
this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id,
|
||||
currency: aTransaction?.currency ?? null,
|
||||
dataSource: aTransaction?.dataSource ?? null,
|
||||
date: new Date(),
|
||||
fee: 0,
|
||||
platformId: null,
|
||||
quantity: null,
|
||||
symbol: null,
|
||||
type: 'BUY',
|
||||
symbol: aTransaction?.symbol ?? null,
|
||||
type: aTransaction?.type ?? 'BUY',
|
||||
unitPrice: null
|
||||
}
|
||||
},
|
||||
|
@ -9,6 +9,7 @@
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder"
|
||||
[transactions]="transactions"
|
||||
(transactionDeleted)="onDeleteTransaction($event)"
|
||||
(transactionToClone)="onCloneTransaction($event)"
|
||||
(transactionToUpdate)="onUpdateTransaction($event)"
|
||||
></gf-transactions-table>
|
||||
</div>
|
||||
|
@ -48,6 +48,10 @@ export class DataService {
|
||||
return this.http.delete<any>(`/api/order/${aId}`);
|
||||
}
|
||||
|
||||
public deleteUser(aId: string) {
|
||||
return this.http.delete<any>(`/api/user/${aId}`);
|
||||
}
|
||||
|
||||
public fetchAccesses() {
|
||||
return this.http.get<Access[]>('/api/access');
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
BIN
apps/client/src/assets/badge-en-google-play.png
Normal file
BIN
apps/client/src/assets/badge-en-google-play.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
@ -12,6 +12,7 @@ export const permissions = {
|
||||
createUserAccount: 'createUserAccount',
|
||||
deleteAccount: 'deleteAcccount',
|
||||
deleteOrder: 'deleteOrder',
|
||||
deleteUser: 'deleteUser',
|
||||
enableSocialLogin: 'enableSocialLogin',
|
||||
enableSubscription: 'enableSubscription',
|
||||
readForeignPortfolio: 'readForeignPortfolio',
|
||||
@ -36,6 +37,7 @@ export function getPermissions(aRole: Role): string[] {
|
||||
permissions.createOrder,
|
||||
permissions.deleteAccount,
|
||||
permissions.deleteOrder,
|
||||
permissions.deleteUser,
|
||||
permissions.readForeignPortfolio,
|
||||
permissions.updateAccount,
|
||||
permissions.updateOrder,
|
||||
|
2
nx.json
2
nx.json
@ -6,7 +6,7 @@
|
||||
".eslintrc.json": "*",
|
||||
"nx.json": "*"
|
||||
},
|
||||
"affected": { "defaultBase": "master" },
|
||||
"affected": { "defaultBase": "origin/main" },
|
||||
"npmScope": "ghostfolio",
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "0.98.0",
|
||||
"version": "1.1.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
@ -68,8 +68,6 @@ model Order {
|
||||
date DateTime
|
||||
fee Float
|
||||
id String @default(uuid())
|
||||
Platform Platform? @relation(fields: [platformId], references: [id])
|
||||
platformId String?
|
||||
quantity Float
|
||||
symbol String
|
||||
type Type
|
||||
@ -85,7 +83,6 @@ model Platform {
|
||||
Account Account[]
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
url String @unique
|
||||
}
|
||||
|
||||
|
@ -144,7 +144,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
|
||||
fee: 30,
|
||||
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
|
||||
platformId: platformDegiro.id,
|
||||
quantity: 50,
|
||||
symbol: 'TSLA',
|
||||
type: Type.BUY,
|
||||
@ -158,7 +157,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
|
||||
fee: 29.9,
|
||||
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
|
||||
platformId: platformCoinbase.id,
|
||||
quantity: 0.5614682,
|
||||
symbol: 'BTCUSD',
|
||||
type: Type.BUY,
|
||||
@ -172,7 +170,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
|
||||
fee: 80.79,
|
||||
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 5,
|
||||
symbol: 'AMZN',
|
||||
type: Type.BUY,
|
||||
@ -186,7 +183,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
type: Type.BUY,
|
||||
@ -200,7 +196,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
type: Type.BUY,
|
||||
@ -214,7 +209,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '347b0430-a84f-4031-a0f9-390399066ad6',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
type: Type.BUY,
|
||||
@ -228,7 +222,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
type: Type.BUY,
|
||||
@ -242,7 +235,6 @@ async function main() {
|
||||
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
|
||||
platformId: platformInteractiveBrokers.id,
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
type: Type.BUY,
|
||||
|
Reference in New Issue
Block a user