You've already forked openaccounting-web
mirror of
https://github.com/openaccounting/oa-web.git
synced 2025-12-09 09:01:24 +13:00
initial commit
This commit is contained in:
31
src/app/account/account.module.ts
Normal file
31
src/app/account/account.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
|
||||
import { AccountsPage } from './accounts';
|
||||
import { NewAccountPage } from './new';
|
||||
import { EditAccountPage } from './edit';
|
||||
import { TreeComponent } from './tree';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AccountsPage,
|
||||
NewAccountPage,
|
||||
EditAccountPage,
|
||||
TreeComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NgbModule,
|
||||
ReactiveFormsModule,
|
||||
SharedModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
exports: [TreeComponent],
|
||||
providers: []
|
||||
})
|
||||
export class AccountModule { }
|
||||
7
src/app/account/accounts.html
Normal file
7
src/app/account/accounts.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<h1>Accounts</h1>
|
||||
|
||||
<div class="section">
|
||||
<account-tree></account-tree>
|
||||
|
||||
<button type="button" class="btn btn-primary" (click)="newAccount()">New Account</button>
|
||||
</div>
|
||||
0
src/app/account/accounts.scss
Normal file
0
src/app/account/accounts.scss
Normal file
19
src/app/account/accounts.ts
Normal file
19
src/app/account/accounts.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-accounts',
|
||||
templateUrl: './accounts.html',
|
||||
styleUrls: ['./accounts.scss']
|
||||
})
|
||||
export class AccountsPage implements OnInit {
|
||||
|
||||
constructor(private router: Router) { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
newAccount() {
|
||||
this.router.navigate(['/accounts/new']);
|
||||
}
|
||||
}
|
||||
54
src/app/account/edit.html
Normal file
54
src/app/account/edit.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<h1>Edit Account</h1>
|
||||
<div class="section">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="name" id="name" type="text" class="form-control" placeholder="Account Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="Currency" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="precision" id="precision" type="number" class="form-control" placeholder="Decimal Places" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="parent" class="col-sm-3 col-form-label">Parent Account</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="parent" formControlName="parent">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName | slice:0:50}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Update</button>
|
||||
<button type="button" class="btn btn-primary" (click)="confirmDelete()">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ng-template #confirmDeleteModal let-c="close" let-d="dismiss">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Confirm delete</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="d()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this account?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="d()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="c()">Delete</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
105
src/app/account/edit.ts
Normal file
105
src/app/account/edit.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Component, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { Account, AccountApi, AccountTree } from '../shared/account';
|
||||
import { AppError } from '../shared/error';
|
||||
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
|
||||
import 'rxjs/add/operator/first';
|
||||
|
||||
@Component({
|
||||
selector: 'app-accounts-edit',
|
||||
templateUrl: 'edit.html'
|
||||
})
|
||||
export class EditAccountPage {
|
||||
|
||||
public form: FormGroup;
|
||||
public selectAccounts: any[];
|
||||
public error: AppError;
|
||||
private account: Account;
|
||||
private accountTree: AccountTree;
|
||||
@ViewChild('confirmDeleteModal') confirmDeleteModal: ElementRef;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private accountService: AccountService,
|
||||
private fb: FormBuilder,
|
||||
private modalService: NgbModal
|
||||
) {
|
||||
this.form = fb.group({
|
||||
'name': [null, Validators.required],
|
||||
'currency': [null],
|
||||
'precision': [null],
|
||||
'parent': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.accountService.getAccountTree().first().subscribe(tree => {
|
||||
this.accountTree = tree;
|
||||
this.selectAccounts = tree.getFlattenedAccounts();
|
||||
let accountId = this.route.snapshot.paramMap.get('id');
|
||||
this.account = tree.accountMap[accountId];
|
||||
|
||||
this.form = fb.group({
|
||||
'name': [this.account.name, Validators.required],
|
||||
'currency': [this.account.currency],
|
||||
'precision': [this.account.precision],
|
||||
'parent': [this.account.parent.id, Validators.required]
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
let account = new AccountApi(this.form.value);
|
||||
let parentAccount = this.accountTree.accountMap[account.parent];
|
||||
|
||||
if(!parentAccount) {
|
||||
this.error = new AppError('Invalid parent account');
|
||||
return;
|
||||
}
|
||||
|
||||
account.id = this.account.id;
|
||||
account.orgId = this.account.orgId;
|
||||
account.debitBalance = parentAccount.debitBalance;
|
||||
account.currency = account.currency || parentAccount.currency;
|
||||
account.precision = account.precision !== null ? account.precision : parentAccount.precision;
|
||||
|
||||
this.log.debug('put account');
|
||||
this.log.debug(account);
|
||||
this.accountService.putAccount(account)
|
||||
.subscribe(
|
||||
account => {
|
||||
this.log.debug(account);
|
||||
this.router.navigate(['/accounts']);
|
||||
},
|
||||
err => {
|
||||
this.error = err;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
confirmDelete() {
|
||||
this.modalService.open(this.confirmDeleteModal).result.then((result) => {
|
||||
this.log.debug('delete');
|
||||
this.accountService.deleteAccount(this.account.id)
|
||||
.subscribe(() => {
|
||||
this.log.debug('successfully deleted account ' + this.account.id);
|
||||
this.router.navigate(['/accounts']);
|
||||
}, error => {
|
||||
this.error = error;
|
||||
})
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel delete');
|
||||
});
|
||||
}
|
||||
}
|
||||
36
src/app/account/new.html
Normal file
36
src/app/account/new.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<h1>New Account</h1>
|
||||
|
||||
<div class="section">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="name" id="name" type="text" class="form-control" placeholder="Account Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="Currency" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="precision" id="precision" type="number" class="form-control" placeholder="Decimal Places" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="parent" class="col-sm-3 col-form-label">Parent Account</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="parent" formControlName="parent">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName | slice:0:50}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
75
src/app/account/new.ts
Normal file
75
src/app/account/new.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { Account, AccountApi, AccountTree } from '../shared/account';
|
||||
import { Util } from '../shared/util';
|
||||
import { AppError } from '../shared/error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-accounts-new',
|
||||
templateUrl: 'new.html'
|
||||
})
|
||||
export class NewAccountPage {
|
||||
|
||||
public form: FormGroup;
|
||||
public selectAccounts: any[];
|
||||
public error: AppError;
|
||||
private accountTree: AccountTree;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private fb: FormBuilder) {
|
||||
|
||||
let org = this.orgService.getCurrentOrg();
|
||||
this.form = fb.group({
|
||||
'name': ['', Validators.required],
|
||||
'currency': [org.currency],
|
||||
'precision': [org.precision],
|
||||
'parent': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.accountService.getAccountTree().subscribe(tree => {
|
||||
this.accountTree = tree;
|
||||
this.selectAccounts = tree.getFlattenedAccounts();
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
let account = new AccountApi(this.form.value);
|
||||
account.id = Util.newGuid();
|
||||
let parentAccount = this.accountTree.accountMap[account.parent];
|
||||
|
||||
if(!parentAccount) {
|
||||
this.error = new AppError('Invalid parent account');
|
||||
return;
|
||||
}
|
||||
|
||||
let org = this.orgService.getCurrentOrg();
|
||||
account.orgId = org.id;
|
||||
account.debitBalance = parentAccount.debitBalance;
|
||||
account.currency = account.currency || parentAccount.currency;
|
||||
account.precision = account.precision !== null ? account.precision : parentAccount.precision;
|
||||
|
||||
this.accountService.newAccount(account)
|
||||
.subscribe(
|
||||
account => {
|
||||
this.router.navigate(['/accounts']);
|
||||
},
|
||||
err => {
|
||||
this.error = err;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
13
src/app/account/tree.html
Normal file
13
src/app/account/tree.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row" *ngFor="let account of accounts$ | async" [ngClass]="{expanded: isExpanded(account), hidden: !isVisible(account)}" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" (click)="click(account)">
|
||||
<span *ngIf="account.children.length" class="expander"></span>
|
||||
<span *ngIf="!account.children.length" class="noexpander"></span>
|
||||
{{account.name | slice:0:30}}
|
||||
<span class="edit"><a [routerLink]="'/accounts/' + account.id + '/edit'">Edit</a></span>
|
||||
</div>
|
||||
<div class="col-4 balance text-right">
|
||||
<span>{{account | accountBalance:'price'}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
73
src/app/account/tree.scss
Normal file
73
src/app/account/tree.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
.name {
|
||||
min-height:2rem;
|
||||
}
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:not(.expanded) > div > .expander {
|
||||
display: inline-block;
|
||||
background-image: url("/assets/plus.svg");
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.row.expanded > div > .expander {
|
||||
display: inline-block;
|
||||
background-image: url("/assets/minus.svg");
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.noexpander {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.edit {
|
||||
display: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.row:hover .edit {
|
||||
display: inline;
|
||||
}
|
||||
.row[depth="1"] .name {
|
||||
padding-left: 15px;
|
||||
}
|
||||
.row[depth="2"] .name {
|
||||
padding-left: 30px;
|
||||
}
|
||||
.row[depth="3"] .name {
|
||||
padding-left: 45px;
|
||||
}
|
||||
.row[depth="4"] .name {
|
||||
padding-left: 60px;
|
||||
}
|
||||
.row[depth="5"] .name {
|
||||
padding-left: 75px;
|
||||
}
|
||||
.row .name {
|
||||
padding-left: 90px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.row[depth="1"] .balance {
|
||||
padding-right: 15px;
|
||||
}
|
||||
.row[depth="2"] .balance {
|
||||
padding-right: 30px;
|
||||
}
|
||||
.row[depth="3"] .balance {
|
||||
padding-right: 45px;
|
||||
}
|
||||
.row[depth="4"] .balance {
|
||||
padding-right: 60px;
|
||||
}
|
||||
.row[depth="5"] .balance {
|
||||
padding-right: 75px;
|
||||
}
|
||||
.row .balance {
|
||||
padding-right: 90px;
|
||||
}
|
||||
}
|
||||
123
src/app/account/tree.ts
Normal file
123
src/app/account/tree.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { Router } from '@angular/router';
|
||||
import { Account } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { ConfigService } from '../core/config.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
@Component({
|
||||
selector: 'account-tree',
|
||||
templateUrl: 'tree.html',
|
||||
styleUrls: ['./tree.scss']
|
||||
})
|
||||
export class TreeComponent implements OnInit {
|
||||
public accounts$: Observable<Account[]>;
|
||||
private accounts: any[];
|
||||
private expandedAccounts: any = {};
|
||||
private visibleAccounts: any = {};
|
||||
private org: Org;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private router: Router,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private configService: ConfigService,
|
||||
private sessionService: SessionService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sessionService.setLoading(true);
|
||||
this.org = this.orgService.getCurrentOrg();
|
||||
this.expandedAccounts = this.configService.get('expandedAccounts') || {};
|
||||
this.visibleAccounts = {};
|
||||
|
||||
this.log.debug('tree init');
|
||||
this.accounts$ = this.accountService.getFlattenedAccounts()
|
||||
.do(accounts => {
|
||||
this.log.debug('NEW TREE');
|
||||
this.sessionService.setLoading(false);
|
||||
this.expandTopLevel(accounts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
expandTopLevel(accounts: Account[]) {
|
||||
|
||||
for(let account of accounts) {
|
||||
if(account.depth === 1 && this.isExpanded(account)) {
|
||||
this.showExpandedDescendants(account);
|
||||
} else if(account.depth === 1) {
|
||||
this.showAccount(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExpanded(account) {
|
||||
return this.expandedAccounts[account.id];
|
||||
}
|
||||
|
||||
toggleExpanded(account) {
|
||||
this.expandedAccounts[account.id] = !this.expandedAccounts[account.id];
|
||||
this.configService.put('expandedAccounts', this.expandedAccounts);
|
||||
}
|
||||
|
||||
isVisible(account) {
|
||||
return this.visibleAccounts[account.id];
|
||||
}
|
||||
|
||||
hideAccount(account) {
|
||||
this.visibleAccounts[account.id] = false;
|
||||
}
|
||||
|
||||
showAccount(account) {
|
||||
this.visibleAccounts[account.id] = true;
|
||||
}
|
||||
|
||||
click(account) {
|
||||
if(account.children.length) {
|
||||
this.toggleExpanded(account);
|
||||
|
||||
if(this.isExpanded(account)) {
|
||||
this.showExpandedDescendants(account);
|
||||
} else {
|
||||
this.hideDescendants(account);
|
||||
}
|
||||
} else {
|
||||
this.router.navigate(['/accounts/' + account.id + '/transactions']);
|
||||
}
|
||||
}
|
||||
|
||||
edit(account: Account) {
|
||||
// let modal = this.modalCtrl.create(EditAccountPage, {account: account});
|
||||
// modal.present();
|
||||
// modal.onWillDismiss(() => {
|
||||
// this.ngOnChanges();
|
||||
// });
|
||||
}
|
||||
|
||||
hideDescendants(account: Account) {
|
||||
account.children.forEach(child => {
|
||||
this.hideAccount(child);
|
||||
this.hideDescendants(child);
|
||||
});
|
||||
}
|
||||
|
||||
showExpandedDescendants(account: Account) {
|
||||
this.showAccount(account);
|
||||
|
||||
account.children.forEach(child => {
|
||||
this.showAccount(child);
|
||||
|
||||
if(this.isExpanded(child)) {
|
||||
this.showExpandedDescendants(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
51
src/app/app-routing.module.ts
Normal file
51
src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { DashboardPage } from './dashboard/dashboard';
|
||||
import { AccountsPage } from './account/accounts';
|
||||
import { NewAccountPage } from './account/new';
|
||||
import { EditAccountPage } from './account/edit';
|
||||
import { TxListPage } from './transaction/list';
|
||||
import { LoginPage } from './user/login';
|
||||
import { LogoutPage } from './user/logout';
|
||||
import { VerifyUserPage } from './user/verify';
|
||||
import { ResetPasswordPage } from './user/reset';
|
||||
import { RegisterPage } from './register/register';
|
||||
import { NewOrgPage } from './org/neworg';
|
||||
import { OrgPage } from './org/org';
|
||||
import { SettingsPage } from './settings/settings';
|
||||
import { PriceDbPage } from './price/pricedb';
|
||||
|
||||
import { ReportsPage } from './reports/reports';
|
||||
import { IncomeReport } from './reports/income';
|
||||
import { BalanceSheetReport } from './reports/balancesheet';
|
||||
|
||||
import { ReconcilePage } from './reconcile/reconcile';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{ path: 'user/verify', component: VerifyUserPage },
|
||||
{ path: 'user/reset-password', component: ResetPasswordPage },
|
||||
{ path: 'dashboard', component: DashboardPage },
|
||||
{ path: 'accounts', component: AccountsPage },
|
||||
{ path: 'accounts/new', component: NewAccountPage },
|
||||
{ path: 'accounts/:id/transactions', component: TxListPage },
|
||||
{ path: 'accounts/:id/edit', component: EditAccountPage },
|
||||
{ path: 'reports', component: ReportsPage },
|
||||
{ path: 'reports/income', component: IncomeReport },
|
||||
{ path: 'reports/balancesheet', component: BalanceSheetReport },
|
||||
{ path: 'login', component: LoginPage },
|
||||
{ path: 'logout', component: LogoutPage },
|
||||
{ path: 'register', component: RegisterPage },
|
||||
{ path: 'orgs/new', component: NewOrgPage },
|
||||
{ path: 'orgs', component: OrgPage },
|
||||
{ path: 'settings', component: SettingsPage },
|
||||
{ path: 'tools/reconcile', component: ReconcilePage },
|
||||
{ path: 'prices', component: PriceDbPage }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [ RouterModule.forRoot(routes, {initialNavigation: false}) ],
|
||||
exports: [ RouterModule ]
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
80
src/app/app.component.html
Normal file
80
src/app/app.component.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!--The content below is only a placeholder and can be replaced.-->
|
||||
<nav class="navbar navbar-expand-sm navbar-dark bg-primary">
|
||||
<a *ngIf="navItems['/login'].hidden" class="navbar-brand" routerLink="/"><img src="assets/oa-logo1.svg" width="176" height="23" alt="Open Accounting"/></a>
|
||||
<a *ngIf="!navItems['/login'].hidden" class="navbar-brand" href="/"><img src="assets/oa-logo1.svg" width="176" height="23" alt="Open Accounting"/></a>
|
||||
|
||||
<button class="navbar-toggler d-md-none" type="button" (click)="isTopNavCollapsed = !isTopNavCollapsed" data-toggle="collapse" aria-controls="leftNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<!-- <span class="navbar-toggler-icon"></span> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 30 30" width="30" height="30" focusable="false"><title>Menu</title><path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-miterlimit="10" d="M4 7h22M4 15h22M4 23h22"/></svg>
|
||||
</button>
|
||||
<div [ngbCollapse]="isTopNavCollapsed" class="collapse navbar-collapse" id="topNav">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">About <span class="sr-only">(current)</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/pricing">Pricing</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/docs/getting-started">Docs</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/api">API</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://github.com/openaccounting" target="_blank">Source Code</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/contact">Contact</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="login">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item" *ngIf="!navItems['/login'].hidden">
|
||||
<a class="nav-link" href="/login">Login</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="!navItems['/login'].hidden">
|
||||
<a class="nav-link" href="/register">Sign Up</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="!navItems['/logout'].hidden">
|
||||
<a class="nav-link" routerLink="/logout">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container-fluid" [ngClass]="{loading: sessionService.isLoading()}">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-3 sidebar" *ngIf="!hideLeftNav" [ngClass]="{display: showLeftNav}">
|
||||
<!-- <span class="navbar-toggler-icon"></span> -->
|
||||
<nav class="left-nav-links" id="leftNav">
|
||||
<ul class="nav">
|
||||
<li *ngFor="let item of leftNav">
|
||||
<div *ngIf="!item.hidden">
|
||||
<a [routerLink]="item.link" [ngClass]="{active: router.isActive(item.link)}">{{item.name}}</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<h5 *ngIf="loggedIn" class="tools">Tools</h5>
|
||||
<ul *ngIf="loggedIn" class="nav">
|
||||
<li *ngFor="let item of toolsNav">
|
||||
<div *ngIf="!item.hidden">
|
||||
<a [routerLink]="item.link" [ngClass]="{active: router.isActive(item.link)}">{{item.name}}</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col content">
|
||||
<button *ngIf="!showLeftNav" [ngClass]="{display: hideLeftNav}" class="navbar-toggler" type="button" (click)="showLeftNav = true; hideLeftNav = false;" data-toggle="collapse" aria-controls="leftNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 30 30" width="30" height="30" focusable="false"><title>Menu</title><path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-miterlimit="10" d="M4 7h22M4 15h22M4 23h22"/></svg>
|
||||
</button>
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Copyright © 2018 Open Accounting, LLC<br>
|
||||
<a href="/tou">Terms of Use</a> | <a href="/privacy-policy">Privacy Policy</a></p>
|
||||
</div>
|
||||
|
||||
76
src/app/app.component.scss
Normal file
76
src/app/app.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
@import '~sass/variables';
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
line-height: 1.5
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar {
|
||||
border-right: 1px solid rgba(0,0,0,.06);
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
#topNav {
|
||||
line-height: 1.5
|
||||
}
|
||||
|
||||
#leftNav {
|
||||
margin-top: 10px;
|
||||
line-height: 2
|
||||
}
|
||||
|
||||
#leftNav li > div {
|
||||
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
#leftNav a {
|
||||
display: block;
|
||||
padding: .25rem 1.5rem;
|
||||
color: #466e9a;
|
||||
}
|
||||
|
||||
#leftNav a.active {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
#leftNav .nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#leftNav .tools {
|
||||
margin: 1rem 0 0 0;
|
||||
padding: .25rem 1.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
cursor: wait;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 0;
|
||||
background-color: #f8fcff;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar:not(.display) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content .navbar-toggler {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content .navbar-toggler:not(.display) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
211
src/app/app.component.ts
Normal file
211
src/app/app.component.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { Logger } from './core/logger';
|
||||
import { SessionService } from './core/session.service';
|
||||
import { ConfigService } from './core/config.service';
|
||||
import { OrgService } from './core/org.service';
|
||||
import { AccountService } from './core/account.service';
|
||||
import { TransactionService } from './core/transaction.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
public showLeftNav: boolean = false;
|
||||
public hideLeftNav: boolean = false;
|
||||
public isTopNavCollapsed: boolean = false;
|
||||
public loggedIn: boolean = false;
|
||||
|
||||
public navItems: any = {
|
||||
'/dashboard': {
|
||||
link: '/dashboard',
|
||||
name: 'Dashboard'
|
||||
},
|
||||
'/accounts': {
|
||||
link: '/accounts',
|
||||
name: 'Accounts'
|
||||
},
|
||||
'/reports': {
|
||||
link: '/reports',
|
||||
name: 'Reports'
|
||||
},
|
||||
'/prices': {
|
||||
link: '/prices',
|
||||
name: 'Price Database',
|
||||
},
|
||||
'/orgs': {
|
||||
link: '/orgs',
|
||||
name: 'Organization'
|
||||
},
|
||||
'/settings': {
|
||||
link: '/settings',
|
||||
name: 'Settings'
|
||||
},
|
||||
'/login': {
|
||||
link: '/login',
|
||||
name: 'Login',
|
||||
hidden: true
|
||||
},
|
||||
'/logout': {
|
||||
link: '/logout',
|
||||
name: 'Logout'
|
||||
},
|
||||
'/tools/reconcile': {
|
||||
link: '/tools/reconcile',
|
||||
name: 'Reconcile'
|
||||
}
|
||||
};
|
||||
|
||||
public leftNav: any[] = [
|
||||
this.navItems['/dashboard'],
|
||||
this.navItems['/accounts'],
|
||||
this.navItems['/reports'],
|
||||
this.navItems['/prices'],
|
||||
this.navItems['/orgs'],
|
||||
this.navItems['/settings']
|
||||
];
|
||||
|
||||
public toolsNav: any[] = [
|
||||
this.navItems['/tools/reconcile']
|
||||
];
|
||||
|
||||
// Allowed unauthenticated links besides login
|
||||
public passthroughLinks: any[] = [
|
||||
'/register',
|
||||
'/user/verify',
|
||||
'/user/reset-password',
|
||||
'/settings'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
public sessionService: SessionService,
|
||||
private configService: ConfigService,
|
||||
private orgService: OrgService,
|
||||
private accountService: AccountService,
|
||||
private transactionService: TransactionService) {
|
||||
this.log.setLevel(Logger.INFO);
|
||||
}
|
||||
|
||||
hideNavItem(link: string) {
|
||||
this.navItems[link].hidden = true;
|
||||
}
|
||||
|
||||
showNavItem(link: string) {
|
||||
this.navItems[link].hidden = false;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.log.info('app init');
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('appComponent: new session');
|
||||
|
||||
//this.dataService.setLoading(false);
|
||||
|
||||
if(!user) {
|
||||
this.loggedIn = false;
|
||||
this.log.debug('no user');
|
||||
this.showLoggedOutMenu();
|
||||
|
||||
let passthrough = false;
|
||||
|
||||
this.passthroughLinks.forEach(link => {
|
||||
if(this.location.path().startsWith(link)) {
|
||||
passthrough = true;
|
||||
}
|
||||
})
|
||||
|
||||
if(passthrough) {
|
||||
this.router.initialNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!org) {
|
||||
this.loggedIn = true;
|
||||
this.log.debug('display new org page');
|
||||
this.showCreateOrgMenu();
|
||||
|
||||
// display new org screen
|
||||
// TODO allow joining of exisitng orgs
|
||||
this.router.navigate(['/orgs/new']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loggedIn = true;
|
||||
|
||||
this.showLoggedInMenu();
|
||||
|
||||
if(
|
||||
this.router.url === '/login' ||
|
||||
this.router.url === '/orgs' ||
|
||||
this.router.url === '/orgs/new'
|
||||
) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.initialNavigation();
|
||||
});
|
||||
|
||||
this.configService.init().subscribe(() => {
|
||||
this.log.debug('config loaded');
|
||||
this.sessionService.init();
|
||||
});
|
||||
|
||||
this.router.events.filter(val => {
|
||||
return val instanceof NavigationEnd;
|
||||
}).subscribe(val => {
|
||||
let event = val as NavigationEnd;
|
||||
if(event.url.match(/^\/accounts\/(.+?)\/transactions/)) {
|
||||
this.hideLeftNav = true;
|
||||
this.showLeftNav = false;
|
||||
} else {
|
||||
this.hideLeftNav = false;
|
||||
this.showLeftNav = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showLoggedInMenu() {
|
||||
this.showNavItem('/dashboard');
|
||||
this.showNavItem('/accounts');
|
||||
this.showNavItem('/reports');
|
||||
this.showNavItem('/prices');
|
||||
this.showNavItem('/orgs');
|
||||
this.showNavItem('/tools/reconcile');
|
||||
this.showNavItem('/logout');
|
||||
this.hideNavItem('/login');
|
||||
}
|
||||
|
||||
showCreateOrgMenu() {
|
||||
this.hideNavItem('/dashboard');
|
||||
this.hideNavItem('/accounts');
|
||||
this.hideNavItem('/reports');
|
||||
this.hideNavItem('/prices');
|
||||
this.hideNavItem('/orgs');
|
||||
this.hideNavItem('/tools/reconcile');
|
||||
this.showNavItem('/logout');
|
||||
this.hideNavItem('/login');
|
||||
}
|
||||
|
||||
showLoggedOutMenu() {
|
||||
this.hideNavItem('/dashboard');
|
||||
this.hideNavItem('/accounts');
|
||||
this.hideNavItem('/reports');
|
||||
this.hideNavItem('/prices');
|
||||
this.hideNavItem('/orgs');
|
||||
this.hideNavItem('/tools/reconcile');
|
||||
this.hideNavItem('/logout');
|
||||
this.showNavItem('/login');
|
||||
}
|
||||
}
|
||||
43
src/app/app.module.ts
Normal file
43
src/app/app.module.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { RegisterModule } from './register/register.module';
|
||||
import { OrgModule } from './org/org.module';
|
||||
import { TransactionModule } from './transaction/transaction.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ReconcileModule } from './reconcile/reconcile.module';
|
||||
import { PriceModule } from './price/price.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NgbModule.forRoot(),
|
||||
AppRoutingModule,
|
||||
CoreModule.forRoot(),
|
||||
DashboardModule,
|
||||
AccountModule,
|
||||
UserModule,
|
||||
RegisterModule,
|
||||
OrgModule,
|
||||
TransactionModule,
|
||||
ReportsModule,
|
||||
SettingsModule,
|
||||
ReconcileModule,
|
||||
PriceModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
253
src/app/core/account.service.spec.ts
Normal file
253
src/app/core/account.service.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { AccountService } from './account.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { EmptyObservable } from 'rxjs/observable/EmptyObservable';
|
||||
import { Logger } from '../core/logger';
|
||||
|
||||
import { AccountApi } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Price } from '../shared/price';
|
||||
import { Org } from '../shared/org';
|
||||
|
||||
var rawAccounts = [
|
||||
new AccountApi({
|
||||
id: '1',
|
||||
orgId: '1',
|
||||
name: 'Root',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '2',
|
||||
orgId: '1',
|
||||
name: 'Assets',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '3',
|
||||
orgId: '1',
|
||||
name: 'Liabilities',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: false,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '4',
|
||||
orgId: '1',
|
||||
name: 'Equity',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: false,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '5',
|
||||
orgId: '1',
|
||||
name: 'Bitcoin',
|
||||
currency: 'BTC',
|
||||
precision: 8,
|
||||
debitBalance: true,
|
||||
parent: '2',
|
||||
balance: 1000000,
|
||||
nativeBalance: 7000
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '6',
|
||||
orgId: '1',
|
||||
name: 'Current Assets',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '2'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '7',
|
||||
orgId: '1',
|
||||
name: 'Checking',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '6',
|
||||
balance: 1000,
|
||||
nativeBalance: 1000
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '8',
|
||||
orgId: '1',
|
||||
name: 'Savings',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '6',
|
||||
balance: 2000,
|
||||
nativeBalance: 2000
|
||||
})
|
||||
];
|
||||
|
||||
class Mock {
|
||||
|
||||
}
|
||||
|
||||
class ApiMock {
|
||||
getAccounts() {
|
||||
return Observable.of(rawAccounts);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionMock {
|
||||
getSessions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
}
|
||||
|
||||
class TransactionMock {
|
||||
getNewTransactions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
|
||||
getDeletedTransactions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
|
||||
getRecentTransactions() {
|
||||
let txs = [
|
||||
new Transaction({
|
||||
id: '1',
|
||||
date: new Date('2018-09-24'),
|
||||
splits: [
|
||||
{
|
||||
accountId: '7',
|
||||
amount: -1000,
|
||||
nativeAmount: -1000
|
||||
},
|
||||
{
|
||||
accountId: '4',
|
||||
amount: 1000,
|
||||
nativeAmount: 1000
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
return Observable.of(txs);
|
||||
}
|
||||
}
|
||||
|
||||
class PriceMock {
|
||||
getPricesNearestInTime() {
|
||||
let prices = [
|
||||
new Price({
|
||||
id: '1',
|
||||
currency: 'BTC',
|
||||
date: new Date('2018-09-24'),
|
||||
price: 10000
|
||||
})
|
||||
];
|
||||
return Observable.of(prices);
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountService', () => {
|
||||
describe('#getAccountTree', () => {
|
||||
it('should correctly create an AccountTree', (done) => {
|
||||
let as = new AccountService(
|
||||
new Logger,
|
||||
new ApiMock() as ApiService,
|
||||
new Mock() as WebSocketService,
|
||||
new TransactionMock() as any,
|
||||
new PriceMock() as any,
|
||||
new SessionMock() as any
|
||||
);
|
||||
|
||||
as['accountWs$'] = Observable.empty();
|
||||
|
||||
as['org'] = new Org({
|
||||
id: '1',
|
||||
currency: 'USD',
|
||||
precision: 2
|
||||
});
|
||||
|
||||
as.getAccountTree().subscribe(tree => {
|
||||
console.log(tree);
|
||||
expect(tree.rootAccount.name).toEqual('Root');
|
||||
expect(tree.rootAccount.depth).toEqual(0);
|
||||
expect(tree.rootAccount.totalBalance).toEqual(3000);
|
||||
expect(tree.rootAccount.totalNativeBalanceCost).toEqual(10000);
|
||||
expect(tree.rootAccount.totalNativeBalancePrice).toEqual(13000);
|
||||
expect(tree.rootAccount.children.length).toEqual(3);
|
||||
expect(tree.rootAccount.children[0].name).toEqual('Assets');
|
||||
expect(tree.rootAccount.children[0].fullName).toEqual('Assets');
|
||||
expect(tree.rootAccount.children[0].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[0].totalBalance).toEqual(3000);
|
||||
expect(tree.rootAccount.children[0].totalNativeBalanceCost).toEqual(10000);
|
||||
expect(tree.rootAccount.children[0].totalNativeBalancePrice).toEqual(13000);
|
||||
expect(tree.rootAccount.children[1].name).toEqual('Equity');
|
||||
expect(tree.rootAccount.children[1].fullName).toEqual('Equity');
|
||||
expect(tree.rootAccount.children[1].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[1].totalBalance).toEqual(0);
|
||||
expect(tree.rootAccount.children[2].name).toEqual('Liabilities');
|
||||
expect(tree.rootAccount.children[2].fullName).toEqual('Liabilities');
|
||||
expect(tree.rootAccount.children[2].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[2].totalBalance).toEqual(0);
|
||||
let assets = tree.rootAccount.children[0];
|
||||
expect(assets.children.length).toEqual(2);
|
||||
expect(assets.children[0].name).toEqual('Bitcoin');
|
||||
expect(assets.children[0].fullName).toEqual('Assets:Bitcoin');
|
||||
expect(assets.children[0].depth).toEqual(2);
|
||||
expect(assets.children[0].totalBalance).toEqual(1000000);
|
||||
expect(assets.children[0].totalNativeBalanceCost).toEqual(7000);
|
||||
expect(assets.children[0].totalNativeBalancePrice).toEqual(10000);
|
||||
expect(assets.children[1].name).toEqual('Current Assets');
|
||||
expect(assets.children[1].fullName).toEqual('Assets:Current Assets');
|
||||
expect(assets.children[1].depth).toEqual(2);
|
||||
expect(assets.children[1].totalBalance).toEqual(3000);
|
||||
let currentAssets = assets.children[1];
|
||||
expect(currentAssets.children.length).toEqual(2);
|
||||
expect(currentAssets.children[0].name).toEqual('Checking');
|
||||
expect(currentAssets.children[0].fullName).toEqual('Assets:Current Assets:Checking');
|
||||
expect(currentAssets.children[0].depth).toEqual(3);
|
||||
expect(currentAssets.children[0].totalBalance).toEqual(1000);
|
||||
expect(currentAssets.children[1].name).toEqual('Savings');
|
||||
expect(currentAssets.children[1].fullName).toEqual('Assets:Current Assets:Savings');
|
||||
expect(currentAssets.children[1].depth).toEqual(3);
|
||||
expect(currentAssets.children[1].totalBalance).toEqual(2000);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRawAccountMap', () => {
|
||||
it('should correctly create a raw account map', (done) => {
|
||||
let as = new AccountService(
|
||||
new Logger,
|
||||
new ApiMock() as ApiService,
|
||||
new Mock() as WebSocketService,
|
||||
new TransactionMock() as any,
|
||||
new PriceMock() as any,
|
||||
new SessionMock() as any
|
||||
);
|
||||
|
||||
as['accountWs$'] = Observable.empty();
|
||||
|
||||
as.getRawAccountMap().subscribe(accountMap => {
|
||||
expect(Object.keys(accountMap).length).toEqual(rawAccounts.length);
|
||||
expect(accountMap['5'].price).toEqual(10000);
|
||||
expect(accountMap['7'].recentTxCount).toEqual(1);
|
||||
expect(accountMap['8'].recentTxCount).toEqual(0);
|
||||
done();
|
||||
}, (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
649
src/app/core/account.service.ts
Normal file
649
src/app/core/account.service.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { concat } from 'rxjs/observable/concat';
|
||||
import { merge } from 'rxjs/observable/merge';
|
||||
import { Account, AccountApi, AccountTree } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Org } from '../shared/org';
|
||||
import { Price } from '../shared/price';
|
||||
import { Message } from '../shared/message';
|
||||
import 'rxjs/add/observable/combineLatest';
|
||||
import 'rxjs/add/operator/concat';
|
||||
import 'rxjs/add/operator/shareReplay';
|
||||
import 'rxjs/add/observable/empty';
|
||||
import 'rxjs/add/operator/startWith';
|
||||
import 'rxjs/add/operator/filter';
|
||||
import 'rxjs/add/operator/debounceTime';
|
||||
import 'rxjs/add/operator/take';
|
||||
import { Util } from '../shared/util';
|
||||
import { personalAccounts } from '../fixtures/personalAccounts';
|
||||
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
private rawAccountMap$: Observable<{[accountId: string]: AccountApi}>;
|
||||
private rawAccountMaps: any = {};
|
||||
private accountWs$: Observable<Message>;
|
||||
private accountSubscription: Subscription;
|
||||
private org: Org;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private txService: TransactionService,
|
||||
private priceService: PriceService,
|
||||
private sessionService: SessionService) {
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org, options]) => {
|
||||
this.log.debug('accountService new session');
|
||||
|
||||
// cleanup after old session
|
||||
this.rawAccountMap$ = null;
|
||||
this.rawAccountMaps = {};
|
||||
|
||||
if(this.accountWs$ && this.org) {
|
||||
this.wsService.unsubscribe('account', this.org.id);
|
||||
this.accountWs$ = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
// subscribe to web socket
|
||||
this.accountWs$ = this.wsService.subscribe('account', org.id);
|
||||
|
||||
if(options.createDefaultAccounts) {
|
||||
this.getAccountTree().take(1).switchMap(tree => {
|
||||
return this.createDefaultAccounts(tree);
|
||||
}).subscribe(accounts => {
|
||||
log.debug('Created default accounts');
|
||||
log.debug(accounts);
|
||||
}, err => {
|
||||
log.error('Error creating default accounts');
|
||||
log.error(err);
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRawSocketAccounts(): Observable<AccountApi> {
|
||||
return this.accountWs$.filter(message => {
|
||||
return message.action === 'create' || message.action === 'update';
|
||||
}).map(message => {
|
||||
return new AccountApi(message.data);
|
||||
});
|
||||
}
|
||||
|
||||
getRawAccountMap(): Observable<{[accountId: string]: AccountApi}> {
|
||||
this.log.debug('getRawAccountMap()');
|
||||
if(!this.rawAccountMap$) {
|
||||
let emptyTx$ = Observable.of(new Transaction({splits: []}));
|
||||
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
|
||||
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
|
||||
|
||||
this.rawAccountMap$ = this.txService.getRecentTransactions().map(recentTxs => {
|
||||
this.log.debug('recentTxs');
|
||||
return recentTxs.reduce((acc, tx) => {
|
||||
tx.splits.forEach(split => {
|
||||
acc[split.accountId] = (acc[split.accountId] || 0) + 1;
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
})
|
||||
.switchMap(txCounts => {
|
||||
this.log.debug('txCounts');
|
||||
this.log.debug(txCounts);
|
||||
return this.apiService.getAccounts().map(rawAccounts => {
|
||||
let rawAccountMap = {};
|
||||
|
||||
rawAccounts.forEach(rawAccount => {
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
rawAccount.recentTxCount = txCounts[rawAccount.id] || 0;
|
||||
})
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('rawAccountMap');
|
||||
this.log.debug(rawAccountMap);
|
||||
return concat(Observable.of(null), this.accountWs$).map(message => {
|
||||
if(message && message.data) {
|
||||
let rawAccount = new AccountApi(message.data);
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
case 'update':
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
break;
|
||||
case 'delete':
|
||||
delete rawAccountMap[rawAccount.id];
|
||||
}
|
||||
}
|
||||
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
return this.priceService.getPricesNearestInTime(new Date()).map(prices => {
|
||||
this.log.debug(prices);
|
||||
prices.forEach(price => {
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
if(rawAccount.currency === price.currency) {
|
||||
rawAccount.price = price.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rawAccountMap;
|
||||
});
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('newtxs');
|
||||
return newTxs$.map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance += split.amount;
|
||||
rawAccount.nativeBalance += split.nativeAmount;
|
||||
rawAccount.recentTxCount++;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('deletedtxs');
|
||||
return deletedTxs$.map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance -= split.amount;
|
||||
rawAccount.nativeBalance -= split.nativeAmount;
|
||||
rawAccount.recentTxCount--;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.debounceTime(500)
|
||||
.shareReplay(1);
|
||||
}
|
||||
|
||||
return this.rawAccountMap$;
|
||||
}
|
||||
|
||||
getRawAccountMapAtDate(date: Date): Observable<{[accountId: string]: AccountApi}> {
|
||||
if(!this.rawAccountMaps[date.getTime()]) {
|
||||
|
||||
let emptyTx$ = Observable.of(new Transaction({splits: []}));
|
||||
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
|
||||
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
|
||||
|
||||
this.rawAccountMaps[date.getTime()] = this.apiService.getAccounts(date).map(rawAccounts => {
|
||||
let rawAccountMap = {};
|
||||
|
||||
rawAccounts.forEach(rawAccount => {
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
})
|
||||
return rawAccountMap;
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
return this.priceService.getPricesNearestInTime(date).map(prices => {
|
||||
this.log.debug(prices);
|
||||
prices.forEach(price => {
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
if(rawAccount.currency === price.currency) {
|
||||
rawAccount.price = price.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rawAccountMap;
|
||||
});
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('newtxs');
|
||||
return newTxs$.filter(tx => {
|
||||
return tx.date < date;
|
||||
}).map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance += split.amount;
|
||||
rawAccount.nativeBalance += split.nativeAmount;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('deletedtxs');
|
||||
return deletedTxs$.filter(tx => {
|
||||
return tx.date < date;
|
||||
}).map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance -= split.amount;
|
||||
rawAccount.nativeBalance -= split.nativeAmount;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.debounceTime(500)
|
||||
.shareReplay(1);
|
||||
}
|
||||
|
||||
return this.rawAccountMaps[date.getTime()];
|
||||
}
|
||||
|
||||
getAccountTree(): Observable<AccountTree> {
|
||||
return this.getRawAccountMap()
|
||||
.map(rawAccountMap => {
|
||||
this.log.debug('accountTree: rawAccountMap');
|
||||
this.log.debug(rawAccountMap);
|
||||
let accountMap = {};
|
||||
let rootAccount = null;
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = new Account(rawAccount);
|
||||
account.parent = null;
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
accountMap[account.id] = account;
|
||||
}
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = accountMap[id];
|
||||
|
||||
if(accountMap[rawAccount.parent]) {
|
||||
account.parent = accountMap[rawAccount.parent];
|
||||
account.parent.children.push(account);
|
||||
// sort children alphabetically
|
||||
account.parent.children.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else {
|
||||
rootAccount = account;
|
||||
}
|
||||
}
|
||||
|
||||
this.log.debug('rootAccount', rootAccount);
|
||||
|
||||
// cache account (for transaction consumers)
|
||||
|
||||
return new AccountTree({
|
||||
rootAccount: rootAccount,
|
||||
accountMap: accountMap
|
||||
});
|
||||
})
|
||||
.map(tree => this._addDepths(tree))
|
||||
.map(tree => this._addFullNames(tree))
|
||||
.map(tree => this._updateBalances(tree));
|
||||
}
|
||||
|
||||
getAccountTreeAtDate(date: Date): Observable<AccountTree> {
|
||||
return this.getRawAccountMapAtDate(date).map(rawAccountMap => {
|
||||
this.log.debug('rawAccounts');
|
||||
this.log.debug(rawAccountMap);
|
||||
let accountMap = {};
|
||||
let rootAccount = null;
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
accountMap[account.id] = account;
|
||||
}
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = accountMap[id];
|
||||
|
||||
if(accountMap[rawAccount.parent]) {
|
||||
account.parent = accountMap[rawAccount.parent];
|
||||
account.parent.children.push(account);
|
||||
// sort children alphabetically
|
||||
account.parent.children.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else {
|
||||
rootAccount = account;
|
||||
}
|
||||
}
|
||||
|
||||
return new AccountTree({
|
||||
rootAccount: rootAccount,
|
||||
accountMap: accountMap
|
||||
});
|
||||
})
|
||||
.map(tree => this._addDepths(tree))
|
||||
.map(tree => this._addFullNames(tree))
|
||||
.map(tree => this._updateBalances(tree));
|
||||
}
|
||||
|
||||
getAccountTreeWithPeriodBalance(startDate: Date, endDate?: Date): Observable<AccountTree> {
|
||||
let startTree$ = this.getAccountTreeAtDate(startDate);
|
||||
let endTree$ = endDate ? this.getAccountTreeAtDate(endDate) : this.getAccountTree();
|
||||
|
||||
return Observable.combineLatest(startTree$, endTree$)
|
||||
.map(([start, end]) => {
|
||||
// function is impure... but convenient
|
||||
// consider making it pure
|
||||
|
||||
for(let accountId in end.accountMap) {
|
||||
let account = end.accountMap[accountId];
|
||||
let startAccount = start.accountMap[accountId];
|
||||
|
||||
this.log.debug(account.name, startAccount ? startAccount.balance : 0, account.balance);
|
||||
|
||||
// TODO maybe there is a better way of dealing with price / balance for non-native currencies
|
||||
let balancePriceDelta = account.balance * account.price - (startAccount ? startAccount.balance * startAccount.price : 0);
|
||||
let balanceDelta = account.balance - (startAccount ? startAccount.balance : 0);
|
||||
|
||||
let weightedPrice = 0;
|
||||
if(balanceDelta) {
|
||||
weightedPrice = balancePriceDelta / balanceDelta;
|
||||
}
|
||||
|
||||
account.balance -= startAccount ? startAccount.balance : 0;
|
||||
account.nativeBalanceCost -= startAccount ? startAccount.nativeBalanceCost : 0;
|
||||
account.nativeBalancePrice -= startAccount ? startAccount.nativeBalancePrice : 0;
|
||||
account.totalBalance -= startAccount ? startAccount.totalBalance : 0;
|
||||
account.totalNativeBalanceCost -= startAccount ? startAccount.totalNativeBalanceCost : 0;
|
||||
account.totalNativeBalancePrice -= startAccount ? startAccount.totalNativeBalancePrice : 0;
|
||||
account.price = weightedPrice;
|
||||
}
|
||||
|
||||
this.log.debug('accountTreeWithPeriodBalance');
|
||||
this.log.debug(end);
|
||||
|
||||
return end;
|
||||
});
|
||||
}
|
||||
|
||||
getFlattenedAccounts(): Observable<any> {
|
||||
return this.getAccountTree().map(tree => {
|
||||
return this._getFlattenedAccounts(tree.rootAccount);
|
||||
});
|
||||
}
|
||||
|
||||
getFlattenedAccountsWithPeriodBalance(startDate: Date, endDate?: Date): Observable<Account[]> {
|
||||
return this.getAccountTreeWithPeriodBalance(startDate, endDate).map(tree => {
|
||||
return this._getFlattenedAccounts(tree.rootAccount);
|
||||
});
|
||||
}
|
||||
|
||||
_getFlattenedAccounts(node: Account): Account[] {
|
||||
let flattened = [];
|
||||
|
||||
for(let account of node.children) {
|
||||
flattened.push(account);
|
||||
flattened = flattened.concat(this._getFlattenedAccounts(account));
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
getAccountByName (accounts: Account[], name: string): Account {
|
||||
for(let account of accounts) {
|
||||
// TODO pass in depth
|
||||
if(account.name === name && account.depth === 1) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
sortAccountsAlphabetically(accounts) {
|
||||
accounts.sort((a, b) => {
|
||||
let nameA = a.name.toLowerCase();
|
||||
let nameB = b.name.toLowerCase();
|
||||
if (nameA < nameB)
|
||||
return -1;
|
||||
if (nameA > nameB)
|
||||
return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
_addDepths(tree: AccountTree): AccountTree {
|
||||
for(let id in tree.accountMap) {
|
||||
let account = tree.accountMap[id];
|
||||
let node = account;
|
||||
|
||||
let depth = 0;
|
||||
while(node.parent) {
|
||||
depth++;
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
account.depth = depth;
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
_addFullNames(tree: AccountTree): AccountTree {
|
||||
for(let id in tree.accountMap) {
|
||||
let account = tree.accountMap[id];
|
||||
let node = account;
|
||||
|
||||
let accountArray = [account.name];
|
||||
|
||||
while(node.parent && node.parent.depth > 0) {
|
||||
node = node.parent;
|
||||
accountArray.unshift(node.name);
|
||||
}
|
||||
|
||||
account.fullName = accountArray.join(':');
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
_updateBalances(tree: AccountTree): AccountTree {
|
||||
// TODO impure function
|
||||
|
||||
// first zero out balances. not necessary if all functions are pure
|
||||
for(let accountId in tree.accountMap) {
|
||||
let account = tree.accountMap[accountId];
|
||||
|
||||
account.totalBalance = account.balance;
|
||||
account.totalNativeBalanceCost = account.nativeBalanceCost;
|
||||
|
||||
if(account.currency === this.org.currency) {
|
||||
account.nativeBalancePrice = account.balance;
|
||||
} else {
|
||||
account.nativeBalancePrice = account.balance * account.price / Math.pow(10, account.precision - this.org.precision);
|
||||
}
|
||||
|
||||
account.totalNativeBalancePrice = account.nativeBalancePrice;
|
||||
}
|
||||
|
||||
// update balances
|
||||
for(let accountId in tree.accountMap) {
|
||||
let account = tree.accountMap[accountId];
|
||||
|
||||
if(!account.children.length) {
|
||||
let parent = account.parent;
|
||||
|
||||
while(parent) {
|
||||
parent.totalNativeBalanceCost += account.totalNativeBalanceCost;
|
||||
parent.totalNativeBalancePrice += account.totalNativeBalancePrice;
|
||||
|
||||
if(parent.currency === account.currency) {
|
||||
parent.totalBalance += account.totalBalance;
|
||||
}
|
||||
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
getAccountTreeFromName(name: string, rootNode: Account) {
|
||||
for(var i = 0; i < rootNode.children.length; i++) {
|
||||
let child = rootNode.children[i];
|
||||
if(child.name === name) {
|
||||
return child;
|
||||
}
|
||||
|
||||
try {
|
||||
let account = this.getAccountTreeFromName(name, child);
|
||||
return account;
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Account not found ' + name);
|
||||
}
|
||||
|
||||
getAccountAtoms(rootNode: Account): Account[] {
|
||||
let accounts = [];
|
||||
|
||||
for(let i = 0; i < rootNode.children.length; i++) {
|
||||
let child = rootNode.children[i];
|
||||
if(!child.children.length) {
|
||||
accounts.push(child);
|
||||
} else {
|
||||
accounts = accounts.concat(this.getAccountAtoms(child));
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
// getSelectBoxAccountAtoms(rootNode: Account): any[] {
|
||||
// var data = [];
|
||||
|
||||
// for(let account of rootNode.children) {
|
||||
// if(!account.children.length) {
|
||||
// data.push({
|
||||
// id: account.id,
|
||||
// name: this.getAccountHierarchyString(account),
|
||||
// debitBalance: account.debitBalance
|
||||
// });
|
||||
// }
|
||||
|
||||
// let childData = this.getSelectBoxAccountAtoms(account);
|
||||
// data = data.concat(childData);
|
||||
// }
|
||||
|
||||
// return data;
|
||||
// }
|
||||
|
||||
accountIsChildOf(account: Account, parent: Account) {
|
||||
for(let child of parent.children) {
|
||||
if(child.id === account.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if(this.accountIsChildOf(account, child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
newAccount(account: AccountApi): Observable<Account> {
|
||||
return this.apiService.postAccount(account)
|
||||
.map(rawAccount => {
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
putAccount(account: AccountApi): Observable<Account> {
|
||||
return this.apiService.putAccount(account)
|
||||
.map(rawAccount => {
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
return account;
|
||||
})
|
||||
}
|
||||
|
||||
deleteAccount(id: string): Observable<any> {
|
||||
return this.apiService.deleteAccount(id);
|
||||
}
|
||||
|
||||
getPeriodStart(): Date {
|
||||
let date = new Date();
|
||||
date.setDate(1);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
createDefaultAccounts(tree: AccountTree): Observable<any> {
|
||||
let assetAccount = tree.getAccountByName('Assets', 1);
|
||||
let equityAccount = tree.getAccountByName('Equity', 1);
|
||||
let liabilityAccount = tree.getAccountByName('Liabilities', 1);
|
||||
let incomeAccount = tree.getAccountByName('Income', 1);
|
||||
let expenseAccount = tree.getAccountByName('Expenses', 1);
|
||||
|
||||
let currency = assetAccount.currency;
|
||||
let precision = assetAccount.precision;
|
||||
|
||||
let accountNameMap = {
|
||||
'Assets': [assetAccount.id, true],
|
||||
'Equity': [equityAccount.id, false],
|
||||
'Liabilities': [liabilityAccount.id, false],
|
||||
'Income': [incomeAccount.id, false],
|
||||
'Expenses': [expenseAccount.id, true]
|
||||
};
|
||||
|
||||
let newAccounts;
|
||||
|
||||
try {
|
||||
newAccounts = personalAccounts.map(data => {
|
||||
let id = Util.newGuid();
|
||||
let [parentId, debitBalance] = accountNameMap[data.parent];
|
||||
|
||||
if(!parentId) {
|
||||
throw new Error('Parent does not exist ' + data.parent);
|
||||
}
|
||||
|
||||
// TODO find a cleaner way of doing this without making assumptions
|
||||
if(['Assets', 'Equity', 'Liabilities', 'Income', 'Expenses'].indexOf(data.parent) > -1) {
|
||||
accountNameMap[data.name] = [id, debitBalance];
|
||||
}
|
||||
|
||||
return new AccountApi({
|
||||
id: id,
|
||||
name: data.name,
|
||||
currency: currency,
|
||||
precision: precision,
|
||||
debitBalance: debitBalance,
|
||||
parent: parentId
|
||||
})
|
||||
});
|
||||
} catch(e) {
|
||||
return new ErrorObservable(e);
|
||||
}
|
||||
|
||||
return this.apiService.postAccounts(newAccounts);
|
||||
}
|
||||
}
|
||||
340
src/app/core/api.service.ts
Normal file
340
src/app/core/api.service.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
||||
import { AccountApi } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Org } from '../shared/org';
|
||||
import { User } from '../shared/user';
|
||||
import { Price } from '../shared/price';
|
||||
import { ApiKey } from '../shared/apikey';
|
||||
import { Invite } from '../shared/invite';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
|
||||
import { catchError, retry } from 'rxjs/operators';
|
||||
import { AppError } from '../shared/error';
|
||||
|
||||
let logger;
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
|
||||
private url: string; // URL to web api
|
||||
private httpOptions = {
|
||||
headers: new HttpHeaders({
|
||||
'content-type': 'application/json',
|
||||
'accept-version': '^0.1.8'
|
||||
})
|
||||
};
|
||||
private orgId: string;
|
||||
private sessionId: string;
|
||||
|
||||
constructor(private log: Logger, private http: HttpClient) {
|
||||
logger = log;
|
||||
}
|
||||
|
||||
setUrl(url: string) {
|
||||
this.log.debug('set url', url);
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
verifyUser(code: string): Observable<any> {
|
||||
return this.http.post<any>(this.url + '/user/verify', {code: code}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
resetPassword(email: string): Observable<any> {
|
||||
return this.http.post<any>(this.url + '/user/reset-password', {email: email}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
confirmResetPassword(password: string, code: string): Observable<User> {
|
||||
return this.http.put<User>(this.url + '/user', {password: password, code: code}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
newSession(email: string, password: string, sessionId: string): Observable<any> {
|
||||
let url = this.url + '/sessions';
|
||||
|
||||
let tempHeaders = new HttpHeaders(this.httpOptions.headers.keys().reduce((acc, current) => {
|
||||
acc[current] = this.httpOptions.headers.get(current);
|
||||
return acc;
|
||||
}, {}));
|
||||
|
||||
tempHeaders = tempHeaders.set('Authorization', 'Basic ' + window.btoa(email + ':' + password));
|
||||
|
||||
return this.http.post<any>(url, {id: sessionId}, {headers: tempHeaders})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
logout() {
|
||||
let url = this.url + '/sessions/' + this.sessionId;
|
||||
this.http.delete<any>(url, this.httpOptions).subscribe(() => {
|
||||
this.removeSessionInfo();
|
||||
});
|
||||
}
|
||||
|
||||
setSession(id: string) {
|
||||
this.sessionId = id;
|
||||
this.httpOptions.headers = this.httpOptions.headers.set('Authorization', 'Basic ' + window.btoa(id + ':'));
|
||||
}
|
||||
|
||||
removeSessionInfo() {
|
||||
this.httpOptions.headers.delete('Authorization');
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
setOrgId(orgId: string) {
|
||||
this.orgId = orgId;
|
||||
}
|
||||
|
||||
getAccounts (date?: Date): Observable<AccountApi[]> {
|
||||
this.log.debug('API getAccounts()');
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts';
|
||||
if(date) {
|
||||
url += '?date=' + date.getTime();
|
||||
}
|
||||
return this.http.get<AccountApi[]>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getTransactionsByAccount (accountId, options: any = {}): Observable<Transaction[]> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + accountId + '/transactions';
|
||||
|
||||
if(Object.keys(options).length) {
|
||||
let optionsArray: string [] = [];
|
||||
|
||||
for(let option in options) {
|
||||
optionsArray.push(option + '=' + options[option]);
|
||||
}
|
||||
|
||||
url += '?' + optionsArray.join('&');
|
||||
}
|
||||
|
||||
return this.http.get<Transaction[]>(url, this.httpOptions)
|
||||
.map(transactions => {
|
||||
return transactions.map(transaction => {
|
||||
// TODO do this on all transactions
|
||||
transaction = new Transaction(transaction);
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getTransactions(options: any = {}): Observable<Transaction[]> {
|
||||
this.log.debug('API getTransactions()');
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions';
|
||||
|
||||
if(Object.keys(options).length) {
|
||||
let optionsArray: string [] = [];
|
||||
|
||||
for(let option in options) {
|
||||
optionsArray.push(option + '=' + options[option]);
|
||||
}
|
||||
|
||||
url += '?' + optionsArray.join('&');
|
||||
}
|
||||
|
||||
return this.http.get<Transaction[]>(url, this.httpOptions)
|
||||
.map(transactions => {
|
||||
return transactions.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postTransaction(transaction: Transaction): Observable<Transaction> {
|
||||
return this.http.post<Transaction>(this.url + '/orgs/' + this.orgId + '/transactions', transaction, this.httpOptions)
|
||||
.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + oldId;
|
||||
return this.http.put<Transaction>(url, transaction, this.httpOptions)
|
||||
.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postAccount(account: AccountApi): Observable<AccountApi> {
|
||||
return this.http.post<AccountApi>(this.url + '/orgs/' + this.orgId + '/accounts', account, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postAccounts(accounts: AccountApi[]): Observable<AccountApi> {
|
||||
return this.http.post<AccountApi[]>(this.url + '/orgs/' + this.orgId + '/accounts', accounts, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putAccount(account: AccountApi): Observable<AccountApi> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + account.id;
|
||||
return this.http.put<AccountApi>(url, account, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteAccount(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getOrg (orgId): Observable<Org> {
|
||||
return this.http.get<Org>(this.url + '/orgs/' + orgId, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getOrgs (): Observable<Org[]> {
|
||||
return this.http.get<Org[]>(this.url + '/orgs', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getUser (): Observable<User> {
|
||||
return this.http.get<User>(this.url + '/user', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postUser(user: User): Observable<User> {
|
||||
return this.http.post<User>(this.url + '/users', user, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putUser(user: User): Observable<User> {
|
||||
return this.http.put<User>(this.url + '/user', user, this.httpOptions)
|
||||
.pipe(catchError(this.handleError))
|
||||
}
|
||||
|
||||
postOrg(org: Org): Observable<Org> {
|
||||
return this.http.post<Org>(this.url + '/orgs', org, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getPricesNearestInTime(date: Date): Observable<Price[]> {
|
||||
let query = '/orgs/' + this.orgId + '/prices?nearestDate=' + date.getTime();
|
||||
return this.http.get<Price[]>(this.url + query, this.httpOptions)
|
||||
.map(prices => {
|
||||
return prices.map(price => {
|
||||
price.date = new Date(price.date);
|
||||
price.inserted = new Date(price.inserted);
|
||||
price.updated = new Date(price.updated);
|
||||
return price;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getPricesByCurrency(currency: string): Observable<Price[]> {
|
||||
let query = '/orgs/' + this.orgId + '/prices?currency=' + currency;
|
||||
return this.http.get<Price[]>(this.url + query, this.httpOptions)
|
||||
.map(prices => {
|
||||
return prices.map(price => {
|
||||
price.date = new Date(price.date);
|
||||
price.inserted = new Date(price.inserted);
|
||||
price.updated = new Date(price.updated);
|
||||
return price;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postPrice(price: Price): Observable<Price> {
|
||||
return this.http.post<Price>(this.url + '/orgs/' + this.orgId + '/prices', price, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deletePrice(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/prices/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getApiKeys(): Observable<ApiKey[]> {
|
||||
return this.http.get<ApiKey[]>(this.url + '/apikeys', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.http.post<ApiKey>(this.url + '/apikeys', key, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.http.put<ApiKey>(this.url + '/apikeys/' + key.id, key, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteApiKey(id: string): Observable<any> {
|
||||
return this.http.delete<any>(this.url + '/apikeys/' + id, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getInvites(): Observable<Invite[]> {
|
||||
return this.http.get<Invite[]>(this.url + '/orgs/' + this.orgId + '/invites', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postInvite(invite: Invite): Observable<Invite> {
|
||||
return this.http.post<Invite>(this.url + '/orgs/' + this.orgId + '/invites', invite, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putInvite(invite: Invite): Observable<Invite> {
|
||||
return this.http.put<Invite>(this.url + '/orgs/' + this.orgId + '/invites/' + invite.id, invite, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteInvite(id: string): Observable<any> {
|
||||
return this.http.delete<any>(this.url + '/orgs/' + this.orgId + '/invites/' + id, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse) {
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// A client-side or network error occurred. Handle it accordingly.
|
||||
logger.error('An error occurred:', error.error.message);
|
||||
return new ErrorObservable(new AppError(error.error.message));
|
||||
} else {
|
||||
// The backend returned an unsuccessful response code.
|
||||
// The response body may contain clues as to what went wrong,
|
||||
logger.error(
|
||||
`Backend returned code ${error.status}, ` +
|
||||
`body was: ${error.error}`);
|
||||
|
||||
logger.error(error);
|
||||
logger.error(error.error.error);
|
||||
|
||||
let appError: AppError;
|
||||
if(error.error.error) {
|
||||
appError = new AppError(error.error.error, error.status);
|
||||
} else if(error.message) {
|
||||
appError = new AppError(error.message, error.status);
|
||||
} else {
|
||||
appError = new AppError('An unexpected error has occurred');
|
||||
}
|
||||
|
||||
return new ErrorObservable(appError);
|
||||
}
|
||||
};
|
||||
}
|
||||
27
src/app/core/apikey.service.ts
Normal file
27
src/app/core/apikey.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiKey } from '../shared/apikey';
|
||||
import { ApiService } from './api.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(private apiService: ApiService) {
|
||||
|
||||
}
|
||||
|
||||
getApiKeys(): Observable<ApiKey[]> {
|
||||
return this.apiService.getApiKeys();
|
||||
}
|
||||
|
||||
newApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.apiService.postApiKey(key);
|
||||
}
|
||||
|
||||
putApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.apiService.putApiKey(key)
|
||||
}
|
||||
|
||||
deleteApiKey(id: string): Observable<any> {
|
||||
return this.apiService.deleteApiKey(id);
|
||||
}
|
||||
}
|
||||
51
src/app/core/config.service.ts
Normal file
51
src/app/core/config.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Org } from '../shared/org';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/of';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
private config: any;
|
||||
|
||||
constructor() {}
|
||||
|
||||
init(): Observable<any> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
return this.config[key];
|
||||
}
|
||||
|
||||
put(key: string, value: any) {
|
||||
this.config[key] = value;
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.config = {
|
||||
server: this.config.server
|
||||
};
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(): Observable<any> {
|
||||
localStorage.setItem('config', JSON.stringify(this.config));
|
||||
|
||||
return Observable.of(this.config);
|
||||
}
|
||||
|
||||
load(): Observable<any> {
|
||||
try {
|
||||
this.config = JSON.parse(localStorage.getItem('config')) || {};
|
||||
} catch(e) {
|
||||
this.config = {};
|
||||
}
|
||||
|
||||
return Observable.of(null);
|
||||
}
|
||||
|
||||
}
|
||||
52
src/app/core/core.module.ts
Normal file
52
src/app/core/core.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
//import { DataService } from './data.service';
|
||||
import { AccountService } from './account.service';
|
||||
import { ConfigService } from './config.service';
|
||||
import { OrgService } from './org.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { UserService } from './user.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { ApiKeyService } from './apikey.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, HttpClientModule],
|
||||
declarations: [],
|
||||
exports: [],
|
||||
providers: []
|
||||
})
|
||||
export class CoreModule {
|
||||
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||
if (parentModule) {
|
||||
throw new Error(
|
||||
'CoreModule is already loaded. Import it in the AppModule only');
|
||||
}
|
||||
}
|
||||
|
||||
static forRoot(): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: CoreModule,
|
||||
providers: [
|
||||
Logger,
|
||||
ApiService,
|
||||
AccountService,
|
||||
ConfigService,
|
||||
OrgService,
|
||||
SessionService,
|
||||
TransactionService,
|
||||
UserService,
|
||||
PriceService,
|
||||
WebSocketService,
|
||||
ApiKeyService
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
50
src/app/core/logger.ts
Normal file
50
src/app/core/logger.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class Logger {
|
||||
|
||||
private logLevel: number;
|
||||
|
||||
static FATAL: number = 0;
|
||||
static ERROR: number = 1;
|
||||
static INFO: number = 2;
|
||||
static DEBUG: number = 3;
|
||||
|
||||
constructor() {
|
||||
this.logLevel = Logger.INFO;
|
||||
}
|
||||
|
||||
setLevel(logLevel: number) {
|
||||
this.logLevel = logLevel;
|
||||
}
|
||||
|
||||
fatal(...params: any[]) {
|
||||
if(this.logLevel >= Logger.FATAL) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.error.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
error(...params: any[]) {
|
||||
if(this.logLevel >= Logger.ERROR) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.error.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
info(...params: any[]) {
|
||||
if(this.logLevel >= Logger.INFO) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.log.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
debug(...params: any[]) {
|
||||
if(this.logLevel >= Logger.DEBUG) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.log.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
82
src/app/core/org.service.ts
Normal file
82
src/app/core/org.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { ConfigService } from './config.service';
|
||||
import { Org } from '../shared/org';
|
||||
import { Invite } from '../shared/invite';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SessionOptions } from '../shared/session-options';
|
||||
|
||||
@Injectable()
|
||||
export class OrgService {
|
||||
private org: Org;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private sessionService: SessionService,
|
||||
private configService: ConfigService) {
|
||||
this.log.debug('orgService constructor');
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('orgService: new session');
|
||||
this.org = org;
|
||||
});
|
||||
}
|
||||
|
||||
getOrg(id: string): Observable<Org> {
|
||||
return this.apiService.getOrg(id);
|
||||
}
|
||||
|
||||
getCurrentOrg(): Org {
|
||||
return this.org;
|
||||
}
|
||||
|
||||
getOrgs(): Observable<Org[]> {
|
||||
return this.apiService.getOrgs();
|
||||
}
|
||||
|
||||
newOrg(org: Org, createDefaultAccounts: boolean): Observable<Org> {
|
||||
let sessionOptions = new SessionOptions({
|
||||
createDefaultAccounts: createDefaultAccounts
|
||||
});
|
||||
|
||||
return this.apiService.postOrg(org)
|
||||
.do(org => {
|
||||
this.org = org;
|
||||
this.configService.put('defaultOrg', this.org.id);
|
||||
this.sessionService.switchOrg(this.org, sessionOptions);
|
||||
});
|
||||
}
|
||||
|
||||
selectOrg(id: string): Observable<Org> {
|
||||
return this.getOrg(id)
|
||||
.do(org => {
|
||||
this.org = org;
|
||||
this.configService.put('defaultOrg', this.org.id);
|
||||
this.sessionService.switchOrg(this.org);
|
||||
});
|
||||
}
|
||||
|
||||
getInvites(): Observable<Invite[]> {
|
||||
return this.apiService.getInvites();
|
||||
}
|
||||
|
||||
newInvite(invite: Invite): Observable<Invite> {
|
||||
return this.apiService.postInvite(invite);
|
||||
}
|
||||
|
||||
acceptInvite(inviteId: string): Observable<Invite> {
|
||||
let invite = new Invite({
|
||||
id: inviteId,
|
||||
accepted: true
|
||||
});
|
||||
|
||||
return this.apiService.putInvite(invite);
|
||||
}
|
||||
|
||||
deleteInvite(inviteId: string): Observable<any> {
|
||||
return this.apiService.deleteInvite(inviteId);
|
||||
}
|
||||
}
|
||||
107
src/app/core/price.service.ts
Normal file
107
src/app/core/price.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { Price } from '../shared/price';
|
||||
import { Org } from '../shared/org';
|
||||
import { Message } from '../shared/message';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import 'rxjs/add/operator/merge';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Injectable()
|
||||
export class PriceService {
|
||||
private org: Org;
|
||||
private priceSubscription: Subscription;
|
||||
private newPrices: Subject<Price>;
|
||||
private deletedPrices: Subject<Price>;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private sessionService: SessionService) {
|
||||
|
||||
this.newPrices = new Subject<Price>();
|
||||
this.deletedPrices = new Subject<Price>();
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('priceService new session');
|
||||
|
||||
// cleanup after old session
|
||||
if(this.priceSubscription) {
|
||||
this.wsService.unsubscribe('price', this.org.id);
|
||||
this.priceSubscription.unsubscribe();
|
||||
this.priceSubscription = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
// subscribe to web socket
|
||||
let priceWs$ = this.wsService.subscribe('price', org.id);
|
||||
|
||||
this.priceSubscription = priceWs$.subscribe(message => {
|
||||
let price = null;
|
||||
|
||||
if(message.data) {
|
||||
price = new Price(message.data);
|
||||
}
|
||||
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
this.newPrices.next(price);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deletedPrices.next(price);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getNewPrices(): Observable<Price> {
|
||||
return this.newPrices.asObservable();
|
||||
}
|
||||
|
||||
getDeletedPrices(): Observable<Price> {
|
||||
return this.deletedPrices.asObservable();
|
||||
}
|
||||
|
||||
getPricesNearestInTime(date: Date): Observable<Price[]> {
|
||||
// TODO make more efficient by mutating state as needed instead of full api call
|
||||
// on every price change
|
||||
let newPrices$ = this.getNewPrices();
|
||||
let deletedPrices$ = this.getDeletedPrices();
|
||||
|
||||
let stream$ = Observable.of(null).concat(newPrices$.merge(deletedPrices$));
|
||||
|
||||
return stream$.switchMap(() => {
|
||||
return this.apiService.getPricesNearestInTime(date);
|
||||
});
|
||||
}
|
||||
|
||||
getPricesByCurrency(currency: string): Observable<Price[]> {
|
||||
return this.apiService.getPricesByCurrency(currency);
|
||||
}
|
||||
|
||||
newPrice(price: Price): Observable<Price> {
|
||||
return this.apiService.postPrice(price);
|
||||
}
|
||||
|
||||
deletePrice(id: string): Observable<any> {
|
||||
return this.apiService.deletePrice(id);
|
||||
}
|
||||
|
||||
updatePrice(price: Price): Observable<Price> {
|
||||
return this.apiService.deletePrice(price.id).switchMap(() => {
|
||||
let newPrice = new Price(price);
|
||||
newPrice.id = Util.newGuid();
|
||||
return this.apiService.postPrice(newPrice);
|
||||
});
|
||||
}
|
||||
}
|
||||
169
src/app/core/session.service.ts
Normal file
169
src/app/core/session.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { User } from '../shared/user';
|
||||
import { Org } from '../shared/org';
|
||||
import { SessionOptions } from '../shared/session-options';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { ConfigService } from './config.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import 'rxjs/add/operator/catch';
|
||||
import 'rxjs/add/operator/switchMap';
|
||||
import 'rxjs/add/operator/map';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
|
||||
private sessions$: Subject<[User, Org, SessionOptions]>;
|
||||
private user: User;
|
||||
private org: Org;
|
||||
private loading: boolean;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private configService: ConfigService,
|
||||
private wsService: WebSocketService) {
|
||||
this.loading = true;
|
||||
|
||||
this.sessions$ = new Subject<[User, Org, SessionOptions]>();
|
||||
}
|
||||
|
||||
getSessions(): Observable<[User, Org, SessionOptions]> {
|
||||
return this.sessions$.asObservable();
|
||||
}
|
||||
|
||||
login(email: string, password: string, sessionId: string): Observable<any> {
|
||||
return this.apiService.newSession(email, password, sessionId).do(() => {
|
||||
this.init(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
init(sessionId?: string) {
|
||||
this.loading = true;
|
||||
let server = this.configService.get('server');
|
||||
|
||||
if(!server) {
|
||||
server = 'https://openaccounting.io:8080/api';
|
||||
this.configService.put('server', server);
|
||||
}
|
||||
|
||||
this.apiService.setUrl(server || 'https://openaccounting.io:8080/api');
|
||||
|
||||
sessionId = sessionId || this.configService.get('sessionId');
|
||||
|
||||
let orgId = this.configService.get('defaultOrg');
|
||||
|
||||
if(!sessionId) {
|
||||
this.loading = false;
|
||||
return this.sessions$.next([null, null, new SessionOptions()]);
|
||||
}
|
||||
|
||||
this.apiService.setSession(sessionId);
|
||||
|
||||
this.apiService.getUser()
|
||||
.catch(err => {
|
||||
this.log.debug('bad session ' + err);
|
||||
this.apiService.removeSessionInfo();
|
||||
this.configService.clear();
|
||||
this.loading = false;
|
||||
return Observable.of(null);
|
||||
})
|
||||
.switchMap(user => {
|
||||
if(!user) {
|
||||
this.loading = false;
|
||||
return Observable.of([null, null, new SessionOptions]);
|
||||
}
|
||||
|
||||
return this.apiService.getOrg(orgId).map(org => {
|
||||
return [user, org];
|
||||
}).catch(err => {
|
||||
this.loading = false;
|
||||
this.log.debug('catching error here');
|
||||
return this.apiService.getOrgs().map(orgs => {
|
||||
if(orgs.length) {
|
||||
let org = orgs[0];
|
||||
this.configService.put('defaultOrg', org.id);
|
||||
return [user, org];
|
||||
}
|
||||
|
||||
return [user, null];
|
||||
})
|
||||
})
|
||||
})
|
||||
.subscribe(([user, org]) => {
|
||||
this.log.debug('new session');
|
||||
this.log.debug(user);
|
||||
this.log.debug(org);
|
||||
this.user = user;
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
this.apiService.setOrgId(org.id);
|
||||
}
|
||||
|
||||
// initialize websocket
|
||||
let matches = server.match(/\/\/(.+?)\//);
|
||||
|
||||
if(matches[1]) {
|
||||
let url = 'wss://' +
|
||||
matches[1] +
|
||||
'/ws';
|
||||
|
||||
this.wsService.init(url, sessionId);
|
||||
|
||||
} else {
|
||||
this.log.debug('Failed to initialize web socket because we can\'t parse server url');
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.sessions$.next([user, org, new SessionOptions()]);
|
||||
})
|
||||
}
|
||||
|
||||
logout() {
|
||||
setTimeout(() => {
|
||||
this.wsService.close();
|
||||
this.apiService.logout();
|
||||
this.log.debug('new session');
|
||||
this.log.debug(null);
|
||||
this.log.debug(null);
|
||||
this.sessions$.next([null, null, new SessionOptions()]);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
switchOrg(org: Org, options?: SessionOptions) {
|
||||
setTimeout(() => {
|
||||
if(!options) {
|
||||
options = new SessionOptions();
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
this.apiService.setOrgId(org.id);
|
||||
this.log.debug('new session');
|
||||
this.log.debug(this.user);
|
||||
this.log.debug(org);
|
||||
this.sessions$.next([this.user, org, options]);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
setTimeout(() => {
|
||||
this.loading = loading;
|
||||
}, 1);
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
getOrg() {
|
||||
return this.org;
|
||||
}
|
||||
}
|
||||
228
src/app/core/transaction.service.ts
Normal file
228
src/app/core/transaction.service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Account } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { Message } from '../shared/message';
|
||||
import 'rxjs/add/operator/do';
|
||||
import 'rxjs/add/operator/merge';
|
||||
import 'rxjs/add/operator/filter';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionService {
|
||||
private transactionLastUpdated: Date;
|
||||
private cache: any;
|
||||
private recentTransactions: Transaction[] = null;
|
||||
private newTxs: Subject<Transaction>;
|
||||
private deletedTxs: Subject<Transaction>;
|
||||
private org: Org;
|
||||
private txSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private sessionService: SessionService) {
|
||||
this.newTxs = new Subject<Transaction>();
|
||||
this.deletedTxs = new Subject<Transaction>();
|
||||
this.transactionLastUpdated = new Date(0);
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('transactionService new session');
|
||||
|
||||
// cleanup from old session
|
||||
if(this.txSubscription) {
|
||||
this.wsService.unsubscribe('transaction', this.org.id);
|
||||
this.txSubscription.unsubscribe();
|
||||
this.txSubscription = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
this.recentTransactions = null;
|
||||
|
||||
let txMessages$ = this.wsService.subscribe('transaction', org.id);
|
||||
|
||||
this.txSubscription = txMessages$.subscribe(message => {
|
||||
let tx = null;
|
||||
if(message.data) {
|
||||
tx = new Transaction(message.data);
|
||||
}
|
||||
|
||||
if(tx && tx.updated) {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
}
|
||||
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
if(this.recentTransactions) {
|
||||
this.recentTransactions.push(tx);
|
||||
}
|
||||
|
||||
this.newTxs.next(tx);
|
||||
break;
|
||||
case 'update':
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions[i] = tx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.deletedTxs.next(tx);
|
||||
this.newTxs.next(tx);
|
||||
break;
|
||||
case 'delete':
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.deletedTxs.next(tx);
|
||||
break;
|
||||
case 'reconnect':
|
||||
this.log.debug('Resyncing transactions');
|
||||
this.log.debug('Fetching transactions since ' + this.transactionLastUpdated);
|
||||
let options = {sinceUpdated: this.transactionLastUpdated.getTime(), sort: 'updated-asc', includeDeleted: 'true'};
|
||||
this.apiService.getTransactions(options).subscribe(txs => {
|
||||
txs.forEach(tx => {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
if(tx.deleted) {
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.deletedTxs.next(tx);
|
||||
} else {
|
||||
if(this.recentTransactions) {
|
||||
this.recentTransactions.push(tx);
|
||||
}
|
||||
this.newTxs.next(tx);
|
||||
}
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getNewTransactions(): Observable<Transaction> {
|
||||
return this.newTxs.asObservable();
|
||||
}
|
||||
|
||||
getDeletedTransactions(): Observable<Transaction> {
|
||||
return this.deletedTxs.asObservable();
|
||||
}
|
||||
|
||||
getRecentTransactions(): Observable<Transaction[]> {
|
||||
if(this.recentTransactions) {
|
||||
return Observable.of(this.recentTransactions);
|
||||
}
|
||||
|
||||
return this.apiService.getTransactions({limit: 50}).do(transactions => {
|
||||
this.recentTransactions = transactions;
|
||||
transactions.forEach(tx => {
|
||||
if(tx.updated > this.transactionLastUpdated) {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getLastTransactions(count: number): Observable<Transaction[]> {
|
||||
return this.getRecentTransactions()
|
||||
.map(txs => {
|
||||
return txs.sort((a, b) => {
|
||||
return b.date.getTime() - a.date.getTime();
|
||||
});
|
||||
})
|
||||
.map(txs => {
|
||||
return txs.slice(0, count);
|
||||
})
|
||||
.switchMap(initialTxs => {
|
||||
let txs = initialTxs;
|
||||
|
||||
return Observable.of(initialTxs)
|
||||
.concat(this.getNewTransactions()
|
||||
.map(tx => {
|
||||
// TODO check date
|
||||
txs.unshift(tx);
|
||||
txs.pop();
|
||||
return txs;
|
||||
}).merge(this.getDeletedTransactions()
|
||||
.map(tx => {
|
||||
for(let i = 0; i < txs.length; i++) {
|
||||
if(txs[i].id === tx.id) {
|
||||
txs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return txs;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getNewTransactionsByAccount(accountId: string): Observable<Transaction> {
|
||||
return this.getNewTransactions().filter(tx => {
|
||||
for(let split of tx.splits) {
|
||||
if(split.accountId === accountId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
getDeletedTransactionsByAccount(accountId: string): Observable<Transaction> {
|
||||
return this.getDeletedTransactions().filter(tx => {
|
||||
for(let split of tx.splits) {
|
||||
if(split.accountId === accountId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
getTransactionsByAccount (accountId: string, options: any = {}): Observable<Transaction[]> {
|
||||
return this.apiService.getTransactionsByAccount(accountId, options);
|
||||
}
|
||||
|
||||
getTransactions(options: any = {}): Observable<Transaction[]> {
|
||||
return this.apiService.getTransactions(options);
|
||||
}
|
||||
|
||||
newTransaction(transaction: Transaction): Observable<Transaction> {
|
||||
return this.apiService.postTransaction(transaction);
|
||||
}
|
||||
|
||||
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
|
||||
return this.apiService.putTransaction(oldId, transaction);
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Observable<any> {
|
||||
return this.apiService.deleteTransaction(id);
|
||||
}
|
||||
}
|
||||
37
src/app/core/user.service.ts
Normal file
37
src/app/core/user.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { User } from '../shared/user';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private user: User;
|
||||
|
||||
constructor(private apiService: ApiService) {
|
||||
|
||||
}
|
||||
|
||||
getUser(): Observable<User> {
|
||||
return this.apiService.getUser();
|
||||
}
|
||||
|
||||
postUser(user: User): Observable<User> {
|
||||
return this.apiService.postUser(user);
|
||||
}
|
||||
|
||||
putUser(user: User): Observable<User> {
|
||||
return this.apiService.putUser(user);
|
||||
}
|
||||
|
||||
verifyUser(code: string): Observable<any> {
|
||||
return this.apiService.verifyUser(code);
|
||||
}
|
||||
|
||||
resetPassword(email: string): Observable<any> {
|
||||
return this.apiService.resetPassword(email);
|
||||
}
|
||||
|
||||
confirmResetPassword(password: string, code: string): Observable<User> {
|
||||
return this.apiService.confirmResetPassword(password, code);
|
||||
}
|
||||
}
|
||||
235
src/app/core/websocket.service.ts
Normal file
235
src/app/core/websocket.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
||||
import { WebSocketSubject } from 'rxjs/observable/dom/WebSocketSubject';
|
||||
import { Message } from '../shared/message';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import 'rxjs/add/operator/filter';
|
||||
import 'rxjs/add/operator/retryWhen';
|
||||
import 'rxjs/add/operator/repeatWhen';
|
||||
import 'rxjs/add/operator/delay';
|
||||
|
||||
var version = '^0.1.8';
|
||||
|
||||
@Injectable()
|
||||
export class WebSocketService {
|
||||
|
||||
private socket$: WebSocketSubject<Message>;
|
||||
private outputSocket$: Subject<Message>;
|
||||
private subscriptions: Message[];
|
||||
private reconnected: boolean;
|
||||
private sequenceNumber: number;
|
||||
private lastPongDate: Date;
|
||||
private closed: boolean;
|
||||
private authErrorCount: number;
|
||||
|
||||
constructor(private log: Logger) {
|
||||
this.reconnected = false;
|
||||
this.subscriptions = [];
|
||||
this.outputSocket$ = new Subject<Message>();
|
||||
this.authErrorCount = 0;
|
||||
}
|
||||
|
||||
init(url: string, key: string) {
|
||||
this.closed = false;
|
||||
this.socket$ = new WebSocketSubject({
|
||||
url: url,
|
||||
openObserver: {
|
||||
next: value => {
|
||||
this.log.debug('websocket connected!');
|
||||
this.sequenceNumber = -1;
|
||||
this.detectSleep();
|
||||
|
||||
if(this.reconnected) {
|
||||
this.authenticate(key);
|
||||
this.sendReconnectMessage();
|
||||
|
||||
this.log.debug('resubscribing to events');
|
||||
this.subscriptions.forEach(message => {
|
||||
this.log.debug(message);
|
||||
this.socket$.next(message);
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
closeObserver: {
|
||||
next: value => {
|
||||
this.log.debug('websocket closed!');
|
||||
this.log.debug(value);
|
||||
|
||||
if(value.code === 4000) {
|
||||
// authentication error
|
||||
// this could be because the socket got reconnected and we need
|
||||
// to send an authenticate message
|
||||
this.authErrorCount++;
|
||||
|
||||
if(this.authErrorCount >= 3) {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(value.code >= 4001) {
|
||||
// other intentional errors we should just stop trying to reconnect
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket$.retryWhen(errors$ => {
|
||||
if(this.closed) {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
return errors$.delay(1000).do(err => {
|
||||
this.log.debug('Websocket error');
|
||||
this.log.debug(err);
|
||||
|
||||
this.reconnected = true;
|
||||
});
|
||||
}).repeatWhen(completed => {
|
||||
if(this.closed) {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
return completed.delay(1000).do(err => {
|
||||
this.log.debug('Reconnecting to websocket because it closed');
|
||||
this.reconnected = true;
|
||||
})
|
||||
}).subscribe(message => {
|
||||
this.log.debug('Received message. Our sequenceNumber is ' + this.sequenceNumber);
|
||||
this.log.debug(message);
|
||||
|
||||
this.authErrorCount = 0;
|
||||
|
||||
if(message.type === 'pong') {
|
||||
this.lastPongDate = new Date();
|
||||
}
|
||||
|
||||
if(message.sequenceNumber === 0 && this.sequenceNumber > 0) {
|
||||
// reconnected on us
|
||||
this.log.debug('Websocket reconnected on us');
|
||||
this.authenticate(key);
|
||||
this.sendReconnectMessage();
|
||||
this.sequenceNumber = 0;
|
||||
return;
|
||||
} else if(message.sequenceNumber !== this.sequenceNumber + 1) {
|
||||
// got a bad sequence number
|
||||
// need to reconnect and resync
|
||||
this.log.debug('Websocket out of sync');
|
||||
this.socket$.error({code: 3791, reason: 'Out of sync'});
|
||||
return;
|
||||
}
|
||||
|
||||
this.sequenceNumber = message.sequenceNumber;
|
||||
this.outputSocket$.next(message);
|
||||
}, err => {
|
||||
this.log.error(err);
|
||||
}, () => {
|
||||
this.log.debug('Websocket complete.');
|
||||
});
|
||||
|
||||
this.authenticate(key);
|
||||
}
|
||||
|
||||
subscribe(type: string, orgId: string): Observable<Message> {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: type,
|
||||
action: 'subscribe',
|
||||
data: orgId
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
this.subscriptions.push(message);
|
||||
|
||||
return this.outputSocket$.filter(message => {
|
||||
return message.type === type || message.type === 'reconnect';
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribe(type: string, orgId: string) {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: type,
|
||||
action: 'unsubscribe',
|
||||
data: orgId
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
this.subscriptions = this.subscriptions.filter(message => {
|
||||
return !(message.type === type && message.data === orgId);
|
||||
});
|
||||
}
|
||||
|
||||
detectSleep() {
|
||||
let lastDate = new Date();
|
||||
let interval = setInterval(() => {
|
||||
let currentDate = new Date();
|
||||
if(currentDate.getTime() - lastDate.getTime() > 10000) {
|
||||
// Detected sleep
|
||||
this.log.debug('Sleep detected! Sending ping.');
|
||||
let date = new Date();
|
||||
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'ping',
|
||||
action: 'ping',
|
||||
data: null
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
setTimeout(() => {
|
||||
this.checkForPong(date);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
lastDate = currentDate;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
checkForPong(date: Date) {
|
||||
if(!this.lastPongDate || this.lastPongDate.getTime() < date.getTime()) {
|
||||
this.log.debug('no pong response');
|
||||
this.socket$.error({code: 3792, reason: 'No pong response'});
|
||||
}
|
||||
}
|
||||
|
||||
sendReconnectMessage() {
|
||||
this.log.debug('notifiyng subscribers of reconnect event');
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'reconnect',
|
||||
action: 'reconnect',
|
||||
data: null
|
||||
});
|
||||
|
||||
this.outputSocket$.next(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.log.debug('Closed websocket');
|
||||
this.closed = true;
|
||||
this.socket$.unsubscribe();
|
||||
}
|
||||
|
||||
authenticate(key: string) {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'authenticate',
|
||||
action: 'authenticate',
|
||||
data: key
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
}
|
||||
}
|
||||
78
src/app/dashboard/dashboard.html
Normal file
78
src/app/dashboard/dashboard.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<h1>Dashboard</h1>
|
||||
<p class="description">
|
||||
<a href="/docs/getting-started" target="_blank">Click here</a> for help getting started.
|
||||
</p>
|
||||
<div class="section">
|
||||
<!-- <div class="card getting-started">
|
||||
<button type="button" class="close" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<div class="card-body">
|
||||
<p><a href="/docs/getting-started" target="_blank">Click here</a> for help getting started.</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="card budget">
|
||||
<div class="card-body">
|
||||
<div class="container-fluid" [ngClass]="{expanded: budgetExpanded}">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<h5>Current spending</h5>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h5>{{expenseAmount | currencyFormat:org.precision}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let expense of expenseAccounts" [routerLink]="'/accounts/'+expense.id+'/transactions'" [ngClass]="{hidden: hiddenExpenses[expense.id]}">
|
||||
<div class="col-8 name">
|
||||
{{expense.fullName | accountName:2}}
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
{{expense.nativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<a [routerLink]="" (click)="toggleExpandedBudget()">
|
||||
<span *ngIf="budgetExpanded">Less</span>
|
||||
<span *ngIf="!budgetExpanded">More</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<a routerLink="/reports/income">See all expenses</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card recent">
|
||||
<div class="card-body">
|
||||
<div class="container-fluid" [ngClass]="{expanded: recentExpanded}">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5>Recent transactions</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let recentTx of recentTxs" [routerLink]="'/accounts/'+recentTx.account.id+'/transactions'" [ngClass]="{hidden: recentTx.hidden}">
|
||||
<div class="col-2 date">
|
||||
{{recentTx.tx.date | date:"M/d"}}
|
||||
</div>
|
||||
<div class="col-6 description">
|
||||
{{recentTx.tx.description}}
|
||||
</div>
|
||||
<div class="col-4 amount" [ngClass]="{'negative': recentTx.split.amount > 0}">
|
||||
{{-recentTx.split.amount | currencyFormat:recentTx.account.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<a [routerLink]="" (click)="toggleExpandedRecent()">
|
||||
<span *ngIf="recentExpanded">Less</span>
|
||||
<span *ngIf="!recentExpanded">More</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
19
src/app/dashboard/dashboard.module.ts
Normal file
19
src/app/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { DashboardPage } from './dashboard';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
DashboardPage
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
SharedModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class DashboardModule { }
|
||||
44
src/app/dashboard/dashboard.scss
Normal file
44
src/app/dashboard/dashboard.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
@import '../../sass/variables';
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.getting-started {
|
||||
position: relative;
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
.card-body {
|
||||
padding: 0
|
||||
}
|
||||
}
|
||||
|
||||
.budget {
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
.expanded .hidden {
|
||||
display: flex
|
||||
}
|
||||
.amount {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.recent {
|
||||
.amount {
|
||||
text-align: right;
|
||||
}
|
||||
.amount.negative {
|
||||
color: $negative;
|
||||
}
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
.expanded .hidden {
|
||||
display: flex
|
||||
}
|
||||
}
|
||||
129
src/app/dashboard/dashboard.ts
Normal file
129
src/app/dashboard/dashboard.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Transaction, Split } from '../shared/transaction';
|
||||
import { Org } from '../shared/org';
|
||||
import { Account, AccountTree } from '../shared/account';
|
||||
import { TxListPage } from '../transaction/list';
|
||||
import { IncomeReport } from '../reports/income';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/operator/take';
|
||||
|
||||
class RecentTx {
|
||||
split: Split;
|
||||
account: Account;
|
||||
tx: Transaction;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: 'dashboard.html',
|
||||
styleUrls: ['./dashboard.scss']
|
||||
})
|
||||
export class DashboardPage implements OnInit {
|
||||
public org: Org;
|
||||
public expenseAmount: number;
|
||||
public budgetExpanded: boolean = false;
|
||||
public recentExpanded: boolean = false;
|
||||
public expenseAccounts: Account[] = [];
|
||||
public hiddenExpenses: any = {};
|
||||
public recentTxs: RecentTx[];
|
||||
private numBudgetItems: number = 5;
|
||||
private numSplits: number = 5;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private txService: TransactionService,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private sessionService: SessionService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sessionService.setLoading(true);
|
||||
this.log.debug('dashboard init');
|
||||
let periodStart = this.accountService.getPeriodStart();
|
||||
|
||||
this.org = this.orgService.getCurrentOrg();
|
||||
this.log.debug('org', this.org);
|
||||
|
||||
let tree$ = this.accountService.getAccountTreeWithPeriodBalance(periodStart);
|
||||
|
||||
tree$.do(tree => {
|
||||
let expenses = tree.getAccountByName('Expenses', 1);
|
||||
|
||||
this.expenseAmount = expenses.totalNativeBalanceCost;
|
||||
|
||||
this.expenseAccounts = tree.getFlattenedAccounts().filter(account => {
|
||||
return tree.accountIsChildOf(account, expenses) && account.recentTxCount;
|
||||
}).sort((a, b) => {
|
||||
return b.recentTxCount - a.recentTxCount;
|
||||
}).slice(0, this.numBudgetItems * 2);
|
||||
|
||||
this.hiddenExpenses = {};
|
||||
|
||||
this.expenseAccounts.forEach((account, index) => {
|
||||
if(index >= this.numBudgetItems) {
|
||||
this.hiddenExpenses[account.id] = true;
|
||||
}
|
||||
});
|
||||
})
|
||||
.switchMap(tree => {
|
||||
let expenses = tree.getAccountByName('Expenses', 1);
|
||||
let income = tree.getAccountByName('Income', 1);
|
||||
|
||||
return this.txService.getLastTransactions(this.numSplits * 4).take(1)
|
||||
.map(txs => {
|
||||
this.log.debug('lastTxs');
|
||||
this.log.debug(txs);
|
||||
return txs.map(tx => {
|
||||
let splits = tx.splits.filter(split => {
|
||||
let account = tree.accountMap[split.accountId];
|
||||
if(!account || !split.amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return tree.accountIsChildOf(account, expenses) || tree.accountIsChildOf(account, income);
|
||||
});
|
||||
|
||||
return splits.map(split => {
|
||||
let recentTx = new RecentTx();
|
||||
recentTx.split = split;
|
||||
recentTx.account = tree.accountMap[split.accountId];
|
||||
recentTx.tx = tx;
|
||||
return recentTx;
|
||||
});
|
||||
}).reduce((acc, recentTxs) => {
|
||||
return acc.concat(recentTxs);
|
||||
}, [])
|
||||
}).map(recentTxs => {
|
||||
return recentTxs.slice(0, this.numSplits * 2);
|
||||
}).map(recentTxs => {
|
||||
return recentTxs.map((recentTx, index) => {
|
||||
if(index >= this.numSplits) {
|
||||
recentTx.hidden = true;
|
||||
}
|
||||
|
||||
return recentTx;
|
||||
})
|
||||
});
|
||||
})
|
||||
.subscribe(recentTxs => {
|
||||
this.log.debug('recentTxs', recentTxs);
|
||||
this.recentTxs = recentTxs;
|
||||
this.sessionService.setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
toggleExpandedBudget() {
|
||||
this.budgetExpanded = !this.budgetExpanded;
|
||||
}
|
||||
|
||||
toggleExpandedRecent() {
|
||||
this.recentExpanded = !this.recentExpanded;
|
||||
}
|
||||
}
|
||||
351
src/app/fixtures/personalAccounts.ts
Normal file
351
src/app/fixtures/personalAccounts.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
export const personalAccounts: any =
|
||||
[
|
||||
{
|
||||
"name": "Checking",
|
||||
"parent": "Assets"
|
||||
},
|
||||
{
|
||||
"name": "Savings",
|
||||
"parent": "Assets"
|
||||
},
|
||||
{
|
||||
"name": "Cash",
|
||||
"parent": "Assets"
|
||||
},
|
||||
{
|
||||
"name": "Opening Balances",
|
||||
"parent": "Equity"
|
||||
},
|
||||
{
|
||||
"name": "Auto Loan",
|
||||
"parent": "Liabilities"
|
||||
},
|
||||
{
|
||||
"name": "Credit Card",
|
||||
"parent": "Liabilities"
|
||||
},
|
||||
{
|
||||
"name": "Mortgage",
|
||||
"parent": "Liabilities"
|
||||
},
|
||||
{
|
||||
"name": "Auto",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Depreciation",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Fees",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Gas",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Interest",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Lease",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Parking",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Repairs/Maintenance",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Taxes",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Tolls",
|
||||
"parent": "Auto"
|
||||
},
|
||||
{
|
||||
"name": "Bank",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "ATM Fees",
|
||||
"parent": "Bank"
|
||||
},
|
||||
{
|
||||
"name": "Credit Card Interest",
|
||||
"parent": "Bank"
|
||||
},
|
||||
{
|
||||
"name": "Foreign Fees",
|
||||
"parent": "Bank"
|
||||
},
|
||||
{
|
||||
"name": "Wire Fees",
|
||||
"parent": "Bank"
|
||||
},
|
||||
{
|
||||
"name": "Books",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Charity",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Child Care",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Clothes",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Computer",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Dental",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Expenses",
|
||||
"parent": "Dental"
|
||||
},
|
||||
{
|
||||
"name": "Insurance",
|
||||
"parent": "Dental"
|
||||
},
|
||||
{
|
||||
"name": "Education",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Entertainment",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Music/Movies",
|
||||
"parent": "Entertainment"
|
||||
},
|
||||
{
|
||||
"name": "Recreation",
|
||||
"parent": "Entertainment"
|
||||
},
|
||||
{
|
||||
"name": "Food",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Eat Out",
|
||||
"parent": "Food"
|
||||
},
|
||||
{
|
||||
"name": "Groceries",
|
||||
"parent": "Food"
|
||||
},
|
||||
{
|
||||
"name": "Gifts",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Gym",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Haircuts",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Housing",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Closing Costs",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Fees",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Interest",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Rent",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Repairs/Maintenance",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Taxes",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Utilities",
|
||||
"parent": "Housing"
|
||||
},
|
||||
{
|
||||
"name": "Internet",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Job",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Kitchen",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Laundry/Dry Clean",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Medical",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Expenses",
|
||||
"parent": "Medical"
|
||||
},
|
||||
{
|
||||
"name": "Insurance",
|
||||
"parent": "Medical"
|
||||
},
|
||||
{
|
||||
"name": "Miscellaneous",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Shipping",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Shoes",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Subscriptions",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Supplies",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Toiletries",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Transportation",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Bus",
|
||||
"parent": "Transportation"
|
||||
},
|
||||
{
|
||||
"name": "Metro",
|
||||
"parent": "Transportation"
|
||||
},
|
||||
{
|
||||
"name": "Taxi/Uber",
|
||||
"parent": "Transportation"
|
||||
},
|
||||
{
|
||||
"name": "Travel",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Hotels",
|
||||
"parent": "Travel"
|
||||
},
|
||||
{
|
||||
"name": "Souvenirs",
|
||||
"parent": "Travel"
|
||||
},
|
||||
{
|
||||
"name": "Transportation",
|
||||
"parent": "Travel"
|
||||
},
|
||||
{
|
||||
"name": "Vision",
|
||||
"parent": "Expenses"
|
||||
},
|
||||
{
|
||||
"name": "Expenses",
|
||||
"parent": "Vision"
|
||||
},
|
||||
{
|
||||
"name": "Insurance",
|
||||
"parent": "Vision"
|
||||
},
|
||||
{
|
||||
"name": "Capital Gains",
|
||||
"parent": "Income"
|
||||
},
|
||||
{
|
||||
"name": "Long Term",
|
||||
"parent": "Capital Gains"
|
||||
},
|
||||
{
|
||||
"name": "Short Term",
|
||||
"parent": "Capital Gains"
|
||||
},
|
||||
{
|
||||
"name": "Untaxed",
|
||||
"parent": "Capital Gains"
|
||||
},
|
||||
{
|
||||
"name": "Dividends",
|
||||
"parent": "Income"
|
||||
},
|
||||
{
|
||||
"name": "Taxed",
|
||||
"parent": "Dividends"
|
||||
},
|
||||
{
|
||||
"name": "Untaxed",
|
||||
"parent": "Dividends"
|
||||
},
|
||||
{
|
||||
"name": "Interest",
|
||||
"parent": "Income"
|
||||
},
|
||||
{
|
||||
"name": "Bonds",
|
||||
"parent": "Interest"
|
||||
},
|
||||
{
|
||||
"name": "Checking/Savings Interest",
|
||||
"parent": "Interest"
|
||||
},
|
||||
{
|
||||
"name": "Salary",
|
||||
"parent": "Income"
|
||||
},
|
||||
{
|
||||
"name": "Taxes",
|
||||
"parent": "Income"
|
||||
},
|
||||
{
|
||||
"name": "Federal",
|
||||
"parent": "Taxes"
|
||||
},
|
||||
{
|
||||
"name": "State",
|
||||
"parent": "Taxes",
|
||||
}
|
||||
];
|
||||
45
src/app/org/neworg.html
Normal file
45
src/app/org/neworg.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<h1>Organization</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Join Organization</h2>
|
||||
|
||||
<p>If you have an invite code, enter it here to join an existing organization.</p>
|
||||
|
||||
<form [formGroup]="joinOrgForm" (ngSubmit)="joinOrgSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="inviteId" class="col-sm-3 col-form-label">Invite Code</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="inviteId" type="text" class="form-control" id="inviteId">
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="joinOrgError" class="error">{{joinOrgError.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!joinOrgForm.valid">Join</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>New Organization</h2>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input formControlName="name" type="text" class="form-control" id="name" placeholder="Organization name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="currency">Currency</label>
|
||||
<input formControlName="currency" type="text" class="form-control" id="currency" placeholder="Currency">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="precision">Decimal Places</label>
|
||||
<input formControlName="precision" type="text" class="form-control" id="precision" placeholder="Decimal Places">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="createDefaultAccounts">Create default accounts<br>(can be customized later)</label>
|
||||
<input formControlName="createDefaultAccounts" id="createDefaultAccounts" type="checkbox" class="form-control" />
|
||||
</div>
|
||||
<p *ngIf="error" class="error">
|
||||
{{error.message}}
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
77
src/app/org/neworg.ts
Normal file
77
src/app/org/neworg.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { Org } from '../shared/org';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-neworg',
|
||||
templateUrl: 'neworg.html'
|
||||
})
|
||||
export class NewOrgPage {
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
public joinOrgForm: FormGroup;
|
||||
public joinOrgError: AppError;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private orgService: OrgService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.form = fb.group({
|
||||
'name': ['', Validators.required],
|
||||
'currency': ['USD', Validators.required],
|
||||
'precision': [2, Validators.required],
|
||||
'createDefaultAccounts': [true, Validators.required]
|
||||
});
|
||||
|
||||
this.joinOrgForm = fb.group({
|
||||
'inviteId': [null, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
//this.dataService.setLoading(true);
|
||||
let org = new Org(this.form.value);
|
||||
org.id = Util.newGuid();
|
||||
|
||||
this.log.debug(org);
|
||||
|
||||
this.orgService.newOrg(org, this.form.value['createDefaultAccounts'])
|
||||
.subscribe(
|
||||
org => {
|
||||
this.log.debug(org);
|
||||
},
|
||||
error => {
|
||||
//this.dataService.setLoading(false);
|
||||
this.log.debug('An error occurred!');
|
||||
this.log.debug(error);
|
||||
this.error = error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
joinOrgSubmit() {
|
||||
this.log.debug('join org');
|
||||
this.log.debug(this.joinOrgForm.value.id);
|
||||
this.orgService.acceptInvite(this.joinOrgForm.value.inviteId)
|
||||
.switchMap(invite => {
|
||||
return this.orgService.selectOrg(invite.orgId)
|
||||
})
|
||||
.subscribe(org => {
|
||||
console.log('joined org ' + org.id);
|
||||
}, err => {
|
||||
this.joinOrgError = err;
|
||||
})
|
||||
}
|
||||
}
|
||||
117
src/app/org/org.html
Normal file
117
src/app/org/org.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<h1>Organization</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Choose Organization</h2>
|
||||
|
||||
<form [formGroup]="chooseOrgForm" (ngSubmit)="chooseOrgSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="id" class="col-sm-3 col-form-label">Current Organization</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="id" formControlName="id">
|
||||
<option *ngFor="let org of orgs" [value]="org.id">
|
||||
{{org.name}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="chooseOrgError" class="error">{{chooseOrgError.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!chooseOrgForm.valid">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Join Organization</h2>
|
||||
|
||||
<form [formGroup]="joinOrgForm" (ngSubmit)="joinOrgSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="inviteId" class="col-sm-3 col-form-label">Invite Code</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="inviteId" type="text" class="form-control" id="inviteId">
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="joinOrgError" class="error">{{joinOrgError.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!joinOrgForm.valid">Join</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div *ngIf="invites !== null"class="section">
|
||||
|
||||
<h2 >Invite to {{currentOrg.name}}</h2>
|
||||
|
||||
<form [formGroup]="inviteForm" (ngSubmit)="inviteSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="email" class="col-sm-3 col-form-label">Email</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="email" type="text" class="form-control" id="email">
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="inviteFormError" class="error">{{inviteFormError.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!inviteForm.valid">Invite</button>
|
||||
</form>
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<strong>Code</strong>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<strong>Email</strong>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<strong>Status</strong>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let invite of invites">
|
||||
<div class="col-3">
|
||||
{{invite.id}}
|
||||
</div>
|
||||
<div class="col-5">
|
||||
{{invite.email}}
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<span *ngIf="invite.accepted">accepted</span>
|
||||
<span *ngIf="!invite.accepted">pending</span>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<a (click)="deleteInvite(invite)">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Create Organization</h2>
|
||||
<form [formGroup]="newOrgForm" (ngSubmit)="newOrgSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="name" type="text" class="form-control" id="name" placeholder="Organization name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="currency" type="text" class="form-control" id="currency" placeholder="Currency">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="precision" type="text" class="form-control" id="precision" placeholder="Decimal Places">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="createDefaultAccounts" class="col-sm-3 col-form-label">Create default accounts<br>(can be customized later)</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="createDefaultAccounts" id="createDefaultAccounts" type="checkbox" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="newOrgError" class="error">{{newOrgError.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!newOrgForm.valid">Create New Organization</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
19
src/app/org/org.module.ts
Normal file
19
src/app/org/org.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NewOrgPage } from './neworg';
|
||||
import { OrgPage } from './org';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
NewOrgPage,
|
||||
OrgPage
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class OrgModule { }
|
||||
151
src/app/org/org.ts
Normal file
151
src/app/org/org.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { User } from '../shared/user';
|
||||
import { Org } from '../shared/org';
|
||||
import { Invite } from '../shared/invite';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-org',
|
||||
templateUrl: 'org.html'
|
||||
})
|
||||
export class OrgPage {
|
||||
public currentOrg: Org;
|
||||
public orgs: Org[] = [];
|
||||
public chooseOrgForm: FormGroup;
|
||||
public chooseOrgError: AppError;
|
||||
public joinOrgForm: FormGroup;
|
||||
public joinOrgError: AppError;
|
||||
public inviteForm: FormGroup;
|
||||
public inviteFormError: AppError;
|
||||
public newOrgForm: FormGroup;
|
||||
public newOrgError: AppError;
|
||||
public invites: Invite[];
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private orgService: OrgService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
|
||||
this.invites = null;
|
||||
|
||||
this.chooseOrgForm = fb.group({
|
||||
'id': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.joinOrgForm = fb.group({
|
||||
'inviteId': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.inviteForm = fb.group({
|
||||
'email': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.newOrgForm = fb.group({
|
||||
'name': ['', Validators.required],
|
||||
'currency': ['', Validators.required],
|
||||
'precision': [null, Validators.required],
|
||||
'createDefaultAccounts': [true, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.currentOrg = this.orgService.getCurrentOrg();
|
||||
|
||||
this.chooseOrgForm.setValue({id: this.currentOrg.id});
|
||||
this.newOrgForm.setValue(
|
||||
{
|
||||
name: '',
|
||||
currency: this.currentOrg.currency,
|
||||
precision: this.currentOrg.precision,
|
||||
createDefaultAccounts: true
|
||||
}
|
||||
);
|
||||
|
||||
this.orgService.getOrgs().subscribe(orgs => {
|
||||
this.orgs = orgs;
|
||||
});
|
||||
|
||||
this.orgService.getInvites().subscribe(invites => {
|
||||
this.invites = invites;
|
||||
});
|
||||
}
|
||||
|
||||
chooseOrgSubmit() {
|
||||
this.log.debug('choose new org');
|
||||
this.log.debug(this.chooseOrgForm.value.id);
|
||||
//this.dataService.setLoading(true);
|
||||
this.orgService.selectOrg(this.chooseOrgForm.value.id).subscribe(org => {
|
||||
this.log.debug('new org');
|
||||
this.log.debug(org);
|
||||
});
|
||||
}
|
||||
|
||||
joinOrgSubmit() {
|
||||
this.log.debug('join org');
|
||||
this.log.debug(this.joinOrgForm.value.id);
|
||||
this.orgService.acceptInvite(this.joinOrgForm.value.inviteId)
|
||||
.switchMap(invite => {
|
||||
return this.orgService.selectOrg(invite.orgId)
|
||||
})
|
||||
.subscribe(org => {
|
||||
console.log('joined org ' + org.id);
|
||||
}, err => {
|
||||
this.joinOrgError = err;
|
||||
})
|
||||
}
|
||||
|
||||
inviteSubmit() {
|
||||
this.log.debug('invite');
|
||||
this.log.debug(this.inviteForm.value);
|
||||
|
||||
let invite = new Invite({email: this.inviteForm.value.email});
|
||||
this.orgService.newInvite(invite).subscribe(invite => {
|
||||
this.invites.push(invite);
|
||||
this.inviteForm.reset();
|
||||
}, err => {
|
||||
this.inviteFormError = err;
|
||||
})
|
||||
}
|
||||
|
||||
newOrgSubmit() {
|
||||
//this.dataService.setLoading(true);
|
||||
let org = new Org(this.newOrgForm.value);
|
||||
org.id = Util.newGuid();
|
||||
this.log.debug(org);
|
||||
|
||||
this.orgService.newOrg(org, this.newOrgForm.value['createDefaultAccounts'])
|
||||
.subscribe(
|
||||
org => {
|
||||
this.log.debug(org);
|
||||
},
|
||||
error => {
|
||||
//this.dataService.setLoading(false);
|
||||
this.log.debug('An error occurred!');
|
||||
this.log.debug(error);
|
||||
this.newOrgError = error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
deleteInvite(invite: Invite) {
|
||||
this.orgService.deleteInvite(invite.id).subscribe(() => {
|
||||
this.invites = this.invites.filter(inv => {
|
||||
return inv.id !== invite.id;
|
||||
});
|
||||
}, err => {
|
||||
this.inviteFormError = err;
|
||||
})
|
||||
}
|
||||
}
|
||||
33
src/app/price/price-modal.html
Normal file
33
src/app/price/price-modal.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Price</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form [formGroup]="form">
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-4 col-form-label">Currency</label>
|
||||
<div class="col-sm-8">
|
||||
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="date" class="col-sm-4 col-form-label">Date</label>
|
||||
<div class="col-sm-8">
|
||||
<input formControlName="date" id="date" type="date" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="price" class="col-sm-4 col-form-label">Price</label>
|
||||
<div class="col-sm-8">
|
||||
<input formControlName="price" id="price" type="number" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="!form.valid">Save</button>
|
||||
</div>
|
||||
0
src/app/price/price-modal.scss
Normal file
0
src/app/price/price-modal.scss
Normal file
97
src/app/price/price-modal.ts
Normal file
97
src/app/price/price-modal.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { AppError } from '../shared/error';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormArray,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { Util } from '../shared/util';
|
||||
import { PriceService } from '../core/price.service';
|
||||
import { Price } from '../shared/price';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
@Component({
|
||||
selector: 'price-modal',
|
||||
templateUrl: './price-modal.html',
|
||||
styleUrls: ['./price-modal.scss']
|
||||
})
|
||||
export class PriceModal {
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
private originalDate: Date;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private log: Logger,
|
||||
private priceService: PriceService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
let dateString = Util.getLocalDateString(new Date());
|
||||
|
||||
this.form = fb.group({
|
||||
'id': [null],
|
||||
'currency': ['', Validators.required],
|
||||
'date': [dateString, Validators.required],
|
||||
'price': [null, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
setData(data: any) {
|
||||
console.log(data);
|
||||
this.originalDate = data.date;
|
||||
this.form.patchValue({
|
||||
id: data.id,
|
||||
currency: data.currency,
|
||||
date: Util.getLocalDateString(data.date),
|
||||
price: data.price
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
this.error = null;
|
||||
|
||||
let date = this.form.value.id ? this.originalDate : new Date();
|
||||
let formDate = Util.getDateFromLocalDateString(this.form.value.date);
|
||||
|
||||
if(formDate.getTime()) {
|
||||
// make the time be at the very end of the day
|
||||
formDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
let sameDay = formDate.getFullYear() === date.getFullYear() &&
|
||||
formDate.getMonth() === date.getMonth() &&
|
||||
formDate.getDate() === date.getDate();
|
||||
|
||||
if(formDate.getTime() && !sameDay) {
|
||||
date = formDate;
|
||||
}
|
||||
|
||||
let price = new Price(this.form.value);
|
||||
price.date = date;
|
||||
|
||||
if(this.form.value.id) {
|
||||
// update
|
||||
this.priceService.updatePrice(price).subscribe(price => {
|
||||
this.activeModal.close();
|
||||
}, err => {
|
||||
this.error = err;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// new price
|
||||
price.id = Util.newGuid();
|
||||
|
||||
this.priceService.newPrice(price).subscribe(price => {
|
||||
this.activeModal.close();
|
||||
}, err => {
|
||||
this.error = err;
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/app/price/price.module.ts
Normal file
27
src/app/price/price.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
|
||||
import { PriceDbPage } from './pricedb';
|
||||
import { PriceModal } from './price-modal';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
PriceDbPage,
|
||||
PriceModal
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NgbModule,
|
||||
ReactiveFormsModule,
|
||||
SharedModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
providers: [],
|
||||
entryComponents: [PriceModal]
|
||||
})
|
||||
export class PriceModule { }
|
||||
36
src/app/price/pricedb.html
Normal file
36
src/app/price/pricedb.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<h1>Price Database</h1>
|
||||
|
||||
<div class="description">
|
||||
If you have different currencies or stocks, you can use the price editor to input their current exchange rates or prices.
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
|
||||
<div class="container-fluid">
|
||||
<div *ngFor="let currency of currencies$ | async">
|
||||
<div class="row" depth="1" [ngClass]="{expanded: isExpanded(currency)}">
|
||||
<div class="col-12" (click)="click(currency)">
|
||||
<span class="expander"></span>
|
||||
{{currency}}
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="isExpanded(currency)">
|
||||
<div class="row" *ngFor="let price of prices$[currency] | async" depth="2">
|
||||
<div class="col-4 date">
|
||||
{{price.date | date:"M/d/y"}}
|
||||
</div>
|
||||
<div class="col-4 price">
|
||||
{{price.price * multiplier | currencyFormat:org.precision:org.currency}}
|
||||
</div>
|
||||
<div class="col-4 edit">
|
||||
<a (click)="editPrice(price)">Edit</a> | <a (click)="deletePrice(price)">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="error" *ngIf="error">{{error}}</p>
|
||||
|
||||
<button type="button" class="btn btn-primary mt-3" (click)="newPrice()">New Price</button>
|
||||
</div>
|
||||
23
src/app/price/pricedb.scss
Normal file
23
src/app/price/pricedb.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
.row[depth="1"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:not(.expanded) > div > .expander {
|
||||
display: inline-block;
|
||||
background-image: url("/assets/plus.svg");
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.row.expanded > div > .expander {
|
||||
display: inline-block;
|
||||
background-image: url("/assets/minus.svg");
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.row[depth="1"] .date {
|
||||
padding-left: 15px;
|
||||
}
|
||||
.row[depth="2"] .date {
|
||||
padding-left: 60px;
|
||||
}
|
||||
101
src/app/price/pricedb.ts
Normal file
101
src/app/price/pricedb.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { PriceService } from '../core/price.service';
|
||||
import { Price } from '../shared/price';
|
||||
import { Org } from '../shared/org';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/forkJoin';
|
||||
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { PriceModal } from './price-modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pricedb',
|
||||
templateUrl: 'pricedb.html',
|
||||
styleUrls: ['./pricedb.scss']
|
||||
})
|
||||
export class PriceDbPage {
|
||||
public org: Org;
|
||||
public currencies$: Observable<string[]>;
|
||||
public prices$: {[currency: string]: Observable<Price[]>};
|
||||
public error: AppError;
|
||||
public multiplier: number;
|
||||
private expandedCurrencies: {[currency: string]: boolean};
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private sessionService: SessionService,
|
||||
private priceService: PriceService,
|
||||
private modalService: NgbModal
|
||||
) {
|
||||
this.org = sessionService.getOrg();
|
||||
this.multiplier = Math.pow(10, this.org.precision);
|
||||
this.expandedCurrencies = {};
|
||||
|
||||
this.prices$ = {};
|
||||
|
||||
this.currencies$ = this.priceService.getPricesNearestInTime(new Date()).map(prices => {
|
||||
return prices.map(price => {
|
||||
return price.currency;
|
||||
}).sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}).do(currencies => {
|
||||
currencies.forEach(currency => {
|
||||
this.prices$[currency] = this.priceService.getPricesByCurrency(currency).do(prices => {
|
||||
this.log.debug('got prices for ' + currency);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isExpanded(currency: string) {
|
||||
return this.expandedCurrencies[currency];
|
||||
}
|
||||
|
||||
click(currency: string) {
|
||||
this.expandedCurrencies[currency] = !this.expandedCurrencies[currency];
|
||||
}
|
||||
|
||||
newPrice() {
|
||||
let modal = this.modalService.open(PriceModal);
|
||||
|
||||
modal.result.then((result) => {
|
||||
this.log.debug('price modal save');
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel price modal');
|
||||
});
|
||||
}
|
||||
|
||||
editPrice(price: Price) {
|
||||
let modal = this.modalService.open(PriceModal);
|
||||
|
||||
modal.componentInstance.setData(price);
|
||||
|
||||
modal.result.then((result) => {
|
||||
this.log.debug('price modal save');
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel price modal');
|
||||
});
|
||||
}
|
||||
|
||||
deletePrice(price: Price) {
|
||||
this.error = null;
|
||||
this.priceService.deletePrice(price.id).subscribe(() => {
|
||||
// do nothing
|
||||
}, err => {
|
||||
this.error = err;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
56
src/app/reconcile/reconcile-modal.html
Normal file
56
src/app/reconcile/reconcile-modal.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Reconcile</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-6">Inflows</div>
|
||||
<div class="col-6">Outflows</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6 inflows">
|
||||
<div class="container-fluid">
|
||||
<div *ngFor="let item of inflows" class="row">
|
||||
<div class="col-3">{{item.tx.date | date:"M/d/y"}}</div>
|
||||
<div class="col-4">{{item.tx.description}}</div>
|
||||
<div class="col-3">{{item.amount | currencyFormat:account.precision:account.currency}}</div>
|
||||
<div class="col-2">
|
||||
<input type="checkbox" (click)="toggleReconciled(item)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 outflows">
|
||||
<div class="container-fluid">
|
||||
<div *ngFor="let item of outflows" class="row">
|
||||
<div class="col-3">{{item.tx.date | date:"M/d/y"}}</div>
|
||||
<div class="col-4">{{item.tx.description}}</div>
|
||||
<div class="col-3">{{item.amount | currencyFormat:account.precision:account.currency}}</div>
|
||||
<div class="col-2">
|
||||
<input type="checkbox" (click)="toggleReconciled(item)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-4 offset-8">
|
||||
Balance: {{balance | currencyFormat:account.precision:account.currency}}<br>
|
||||
Reconciled: {{reconciled | currencyFormat:account.precision:account.currency}}<br>
|
||||
Difference: {{(balance - reconciled) | currencyFormat:account.precision:account.currency}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">Complete</button>
|
||||
</div>
|
||||
5
src/app/reconcile/reconcile-modal.scss
Normal file
5
src/app/reconcile/reconcile-modal.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.inflows,
|
||||
.outflows {
|
||||
height: 300px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
226
src/app/reconcile/reconcile-modal.ts
Normal file
226
src/app/reconcile/reconcile-modal.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Transaction, Split } from '../shared/transaction';
|
||||
import { Account, AccountTree } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { AppError } from '../shared/error';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormArray,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { Util } from '../shared/util';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/from';
|
||||
import 'rxjs/add/operator/mergeMap';
|
||||
import { Reconciliation } from './reconciliation';
|
||||
|
||||
class TxItem {
|
||||
tx: Transaction;
|
||||
amount: number;
|
||||
splitIndex: number;
|
||||
reconciled: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'reconcile-modal',
|
||||
templateUrl: './reconcile-modal.html',
|
||||
styleUrls: ['./reconcile-modal.scss']
|
||||
})
|
||||
export class ReconcileModal {
|
||||
|
||||
public account: Account;
|
||||
public reconciliation: Reconciliation;
|
||||
public items: TxItem[];
|
||||
public inflows: TxItem[];
|
||||
public outflows: TxItem[];
|
||||
public form: FormGroup;
|
||||
public balance: number;
|
||||
public reconciled: number;
|
||||
public error: AppError;
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private log: Logger,
|
||||
private txService: TransactionService,
|
||||
private sessionService: SessionService,
|
||||
private fb: FormBuilder
|
||||
) {}
|
||||
|
||||
setData(account: Account, rec: Reconciliation) {
|
||||
this.account = account;
|
||||
|
||||
this.inflows = [];
|
||||
this.outflows = [];
|
||||
this.reconciliation = rec;
|
||||
|
||||
this.balance = rec.endBalance;
|
||||
this.reconciled = rec.startBalance;
|
||||
|
||||
let txs$ = this.txService.getTransactionsByAccount(this.account.id);
|
||||
let newTxs$ = this.txService.getNewTransactionsByAccount(this.account.id);
|
||||
let deletedTxs$ = this.txService.getDeletedTransactionsByAccount(this.account.id);
|
||||
|
||||
txs$.mergeMap(txs => txs).concat(newTxs$)
|
||||
.filter(tx => {
|
||||
let data = tx.getData();
|
||||
let reconciled = true;
|
||||
|
||||
let reconciledSplits = Object.keys(data.reconciledSplits || []).map(index => parseInt(index));
|
||||
tx.splits.forEach((split, index) => {
|
||||
if(split.accountId === this.account.id && reconciledSplits.indexOf(index) === -1) {
|
||||
reconciled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return !reconciled;
|
||||
})
|
||||
.subscribe(tx => {
|
||||
// insert tx into list
|
||||
this.addTransaction(tx);
|
||||
});
|
||||
|
||||
deletedTxs$.subscribe(tx => {
|
||||
this.removeTransaction(tx);
|
||||
// remove tx from list
|
||||
});
|
||||
}
|
||||
|
||||
addTransaction(tx: Transaction) {
|
||||
tx.splits.forEach((split, index) => {
|
||||
if(split.accountId !== this.account.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = new TxItem();
|
||||
item.tx = tx;
|
||||
item.amount = Math.abs(split.amount);
|
||||
item.splitIndex = index;
|
||||
item.reconciled = false;
|
||||
|
||||
if(split.amount >= 0) {
|
||||
this.inflows.push(item);
|
||||
} else {
|
||||
this.outflows.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
this.sort();
|
||||
}
|
||||
|
||||
removeTransaction(tx: Transaction) {
|
||||
for(let i = 0; i < this.inflows.length; i++) {
|
||||
let item = this.inflows[i];
|
||||
|
||||
if(item.tx.id === tx.id) {
|
||||
this.inflows.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for(let i = 0; i < this.outflows.length; i++) {
|
||||
let item = this.outflows[i];
|
||||
|
||||
if(item.tx.id === tx.id) {
|
||||
this.outflows.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort() {
|
||||
this.inflows.sort((a, b) => {
|
||||
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
|
||||
|
||||
if(dateDiff) {
|
||||
return dateDiff;
|
||||
}
|
||||
|
||||
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
|
||||
|
||||
if(insertedDiff) {
|
||||
return insertedDiff;
|
||||
}
|
||||
});
|
||||
|
||||
this.outflows.sort((a, b) => {
|
||||
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
|
||||
|
||||
if(dateDiff) {
|
||||
return dateDiff;
|
||||
}
|
||||
|
||||
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
|
||||
|
||||
if(insertedDiff) {
|
||||
return insertedDiff;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleReconciled(item: TxItem) {
|
||||
item.reconciled = !item.reconciled;
|
||||
|
||||
let data = item.tx.getData();
|
||||
|
||||
if(item.reconciled) {
|
||||
if(!data.reconciledSplits) {
|
||||
data.reconciledSplits = {};
|
||||
}
|
||||
|
||||
data.reconciledSplits[item.splitIndex] = this.reconciliation.endDate;
|
||||
|
||||
if(this.account.debitBalance) {
|
||||
this.reconciled += item.tx.splits[item.splitIndex].amount;
|
||||
} else {
|
||||
this.reconciled -= item.tx.splits[item.splitIndex].amount;
|
||||
}
|
||||
} else {
|
||||
if(!data.reconciledSplits) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete data.reconciledSplits[item.splitIndex];
|
||||
if(this.account.debitBalance) {
|
||||
this.reconciled -= item.tx.splits[item.splitIndex].amount;
|
||||
} else {
|
||||
this.reconciled += item.tx.splits[item.splitIndex].amount;
|
||||
}
|
||||
}
|
||||
|
||||
item.tx.setData(data);
|
||||
}
|
||||
|
||||
save() {
|
||||
if(this.balance !== this.reconciled) {
|
||||
this.error = new AppError('Reconciled amount doesn\'t match balance');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setLoading(true);
|
||||
|
||||
let txs = this.inflows.filter(item => item.reconciled).map(item => item.tx);
|
||||
|
||||
txs = txs.concat(this.outflows.filter(item => item.reconciled).map(item => item.tx));
|
||||
|
||||
Observable.from(txs).mergeMap(tx => {
|
||||
let oldId = tx.id;
|
||||
tx.id = Util.newGuid();
|
||||
|
||||
return this.txService.putTransaction(oldId, tx);
|
||||
}, 8).subscribe(tx => {
|
||||
this.log.debug('Saved tx ' + tx.id);
|
||||
}, err => {
|
||||
this.error = err;
|
||||
this.sessionService.setLoading(false);
|
||||
}, () => {
|
||||
this.sessionService.setLoading(false);
|
||||
this.activeModal.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
62
src/app/reconcile/reconcile.html
Normal file
62
src/app/reconcile/reconcile.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<h1>Reconcile Account</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Select Account</h2>
|
||||
|
||||
<form class="form-inline" [formGroup]="accountForm" (ngSubmit)="onChooseAccount()">
|
||||
<div class="form-group mx-sm-3 mb-2">
|
||||
<label for="accountId" class="sr-only">Account</label>
|
||||
<select class="form-control" id="accountId" formControlName="accountId">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName | slice:0:50}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Select Account</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>New Reconciliation</h2>
|
||||
|
||||
<form [formGroup]="newReconcile" (ngSubmit)="startReconcile()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Start Date</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="startDate" id="startDate" type="date" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-3 col-form-label">Beginning Balance</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="startBalance" id="startBalance" type="text" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">End Date</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="endDate" id="endDate" type="date" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="currency" class="col-sm-3 col-form-label">Ending Balance</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="endBalance" id="endBalance" type="text" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!newReconcile.valid">Start Reconciliation</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Past Reconciliations</h2>
|
||||
|
||||
<div *ngFor="let rec of pastReconciliations">
|
||||
Period: {{rec.startDate | date:"M/d/y"}} - {{rec.endDate | date:"M/d/y"}}<br>
|
||||
Beginning Balance: {{rec.startBalance | currencyFormat:account.precision:account.currency}}<br>
|
||||
Ending Balance: {{rec.endBalance | currencyFormat:account.precision:account.currency}}<br>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
src/app/reconcile/reconcile.module.ts
Normal file
27
src/app/reconcile/reconcile.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
|
||||
import { ReconcilePage } from './reconcile';
|
||||
import { ReconcileModal } from './reconcile-modal';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ReconcilePage,
|
||||
ReconcileModal
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NgbModule,
|
||||
ReactiveFormsModule,
|
||||
SharedModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
providers: [],
|
||||
entryComponents: [ReconcileModal]
|
||||
})
|
||||
export class ReconcileModule { }
|
||||
226
src/app/reconcile/reconcile.ts
Normal file
226
src/app/reconcile/reconcile.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
import { Account, AccountApi, AccountTree } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ReconcileModal } from './reconcile-modal';
|
||||
import { Reconciliation } from './reconciliation';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reconcile',
|
||||
templateUrl: 'reconcile.html'
|
||||
})
|
||||
export class ReconcilePage {
|
||||
|
||||
public accountForm: FormGroup;
|
||||
public newReconcile: FormGroup;
|
||||
public selectAccounts: any[];
|
||||
public account: Account;
|
||||
public pastReconciliations: Reconciliation[];
|
||||
public unreconciledTxs: Transaction[];
|
||||
public error: AppError;
|
||||
private accountTree: AccountTree;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private log: Logger,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private txService: TransactionService,
|
||||
private fb: FormBuilder,
|
||||
private modalService: NgbModal) {
|
||||
|
||||
let org = this.orgService.getCurrentOrg();
|
||||
this.accountForm = fb.group({
|
||||
'accountId': [null, Validators.required]
|
||||
});
|
||||
|
||||
this.newReconcile = fb.group({
|
||||
'startDate': ['', Validators.required],
|
||||
'startBalance': [{value: 0, disabled: true}, Validators.required],
|
||||
'endDate': ['', Validators.required],
|
||||
'endBalance': [0, Validators.required]
|
||||
});
|
||||
|
||||
this.accountService.getAccountTree().subscribe(tree => {
|
||||
this.accountTree = tree;
|
||||
this.selectAccounts = tree.getFlattenedAccounts();
|
||||
});
|
||||
}
|
||||
|
||||
onChooseAccount() {
|
||||
let account = this.accountTree.accountMap[this.accountForm.value.accountId];
|
||||
|
||||
if(!account) {
|
||||
this.error = new AppError('Invalid account');
|
||||
return;
|
||||
}
|
||||
|
||||
this.account = account;
|
||||
|
||||
this.processTransactions();
|
||||
}
|
||||
|
||||
startReconcile() {
|
||||
let value = this.newReconcile.getRawValue();
|
||||
|
||||
let rec = new Reconciliation();
|
||||
rec.startDate = Util.getDateFromLocalDateString(value.startDate);
|
||||
rec.endDate = Util.getDateFromLocalDateString(value.endDate);
|
||||
rec.startBalance = Math.round(parseFloat(value.startBalance) * Math.pow(10, this.account.precision));
|
||||
rec.endBalance = Math.round(parseFloat(value.endBalance) * Math.pow(10, this.account.precision));
|
||||
|
||||
this.log.debug(rec);
|
||||
|
||||
let modal = this.modalService.open(ReconcileModal, {size: 'lg'});
|
||||
|
||||
modal.componentInstance.setData(this.account, rec, this.unreconciledTxs);
|
||||
|
||||
modal.result.then((result) => {
|
||||
this.log.debug('reconcile modal save');
|
||||
this.pastReconciliations.unshift(rec);
|
||||
|
||||
this.newReconcile.patchValue(
|
||||
{
|
||||
startDate: Util.getLocalDateString(rec.endDate),
|
||||
startBalance: rec.endBalance / Math.pow(10, this.account.precision),
|
||||
endBalance: 0,
|
||||
endDate: ''
|
||||
}
|
||||
);
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel reconcile modal');
|
||||
});
|
||||
}
|
||||
|
||||
processTransactions() {
|
||||
// Get all transactions for account
|
||||
// Figure out reconciliations
|
||||
// startDate is date of first transaction
|
||||
// add up reconciled splits for given endDate to get endBalance
|
||||
// sort by most recent first
|
||||
// most recent endDate is used for startDate
|
||||
// most recent endBalance is used for startBalance
|
||||
// guess at next endDate
|
||||
|
||||
this.unreconciledTxs = [];
|
||||
this.pastReconciliations = [];
|
||||
|
||||
this.txService.getTransactionsByAccount(this.account.id).subscribe(txs => {
|
||||
let reconcileMap: {[date: number]: Reconciliation} = {};
|
||||
|
||||
let firstStartDate: Date = null;
|
||||
let firstEndDate: Date = null;
|
||||
|
||||
txs.forEach(tx => {
|
||||
if(!firstStartDate || (!firstEndDate && tx.date < firstStartDate)) {
|
||||
firstStartDate = tx.date;
|
||||
}
|
||||
|
||||
let data = tx.getData();
|
||||
|
||||
if(!data.reconciledSplits) {
|
||||
this.unreconciledTxs.push(tx);
|
||||
return;
|
||||
}
|
||||
|
||||
let reconciled = true;
|
||||
let splitIndexes = Object.keys(data.reconciledSplits).map(index => parseInt(index));
|
||||
|
||||
tx.splits.forEach((split, index) => {
|
||||
if(split.accountId !== this.account.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(splitIndexes.indexOf(index) === -1) {
|
||||
reconciled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let endDate = new Date(data.reconciledSplits[index]);
|
||||
|
||||
if(!firstEndDate || endDate < firstEndDate) {
|
||||
firstEndDate = endDate;
|
||||
firstStartDate = new Date(tx.date);
|
||||
}
|
||||
|
||||
if(endDate.getTime() === firstEndDate.getTime() && tx.date < firstStartDate) {
|
||||
firstStartDate = new Date(tx.date);
|
||||
}
|
||||
|
||||
if(!reconcileMap[endDate.getTime()]) {
|
||||
reconcileMap[endDate.getTime()] = new Reconciliation();
|
||||
reconcileMap[endDate.getTime()].endDate = endDate;
|
||||
reconcileMap[endDate.getTime()].net = 0;
|
||||
}
|
||||
|
||||
let r = reconcileMap[endDate.getTime()];
|
||||
|
||||
if(this.account.debitBalance) {
|
||||
r.net += split.amount;
|
||||
} else {
|
||||
r.net -= split.amount;
|
||||
}
|
||||
});
|
||||
|
||||
if(!reconciled) {
|
||||
this.unreconciledTxs.push(tx);
|
||||
}
|
||||
});
|
||||
|
||||
// Figure out starting date, beginning balance and ending balance
|
||||
let dates = Object.keys(reconcileMap).sort((a, b) => {
|
||||
return parseInt(a) - parseInt(b);
|
||||
}).map(time => {
|
||||
return new Date(parseInt(time));
|
||||
});
|
||||
|
||||
if(!dates.length) {
|
||||
if(firstStartDate) {
|
||||
this.newReconcile.patchValue({startDate: Util.getLocalDateString(firstStartDate)});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let firstRec = reconcileMap[dates[0].getTime()];
|
||||
firstRec.startDate = firstStartDate;
|
||||
firstRec.startBalance = 0;
|
||||
firstRec.endBalance = firstRec.net;
|
||||
|
||||
this.pastReconciliations.unshift(firstRec);
|
||||
|
||||
let lastRec = firstRec;
|
||||
|
||||
for(let i = 1; i < dates.length; i++) {
|
||||
let rec = reconcileMap[dates[i].getTime()];
|
||||
rec.startDate = new Date(lastRec.endDate);
|
||||
rec.startBalance = lastRec.endBalance;
|
||||
rec.endBalance = rec.startBalance + rec.net;
|
||||
this.pastReconciliations.unshift(rec);
|
||||
lastRec = rec;
|
||||
}
|
||||
|
||||
this.newReconcile.patchValue(
|
||||
{
|
||||
startDate: Util.getLocalDateString(lastRec.endDate),
|
||||
startBalance: lastRec.endBalance / Math.pow(10, this.account.precision)
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
7
src/app/reconcile/reconciliation.ts
Normal file
7
src/app/reconcile/reconciliation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class Reconciliation {
|
||||
startDate: Date;
|
||||
startBalance: number;
|
||||
endDate: Date;
|
||||
endBalance: number;
|
||||
net: number;
|
||||
}
|
||||
54
src/app/register/register.html
Normal file
54
src/app/register/register.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<h1>Register</h1>
|
||||
|
||||
<div class="section">
|
||||
<form *ngIf="!registered" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="firstName" class="col-sm-3 col-form-label">First Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="firstName" id="firstName" type="text" class="form-control" placeholder="First Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="lastName" class="col-sm-3 col-form-label">Last Name</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="lastName" id="lastName" type="text" class="form-control" placeholder="Last Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="email" class="col-sm-3 col-form-label">Email</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="email" id="email" type="email" class="form-control" placeholder="Email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="password" class="col-sm-3 col-form-label">Password</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="password" id="password" type="password" class="form-control" placeholder="Password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="password2" class="col-sm-3 col-form-label">Repeat Password</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="password2" id="password2" type="password" class="form-control" placeholder="Repeat Password" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="form.controls.password2.errors?.mismatchedPassword">
|
||||
Passwords do not match.
|
||||
</p>
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-3">
|
||||
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="agreeToTerms" id="agreeToTerms" type="checkbox" class="mr-2" />
|
||||
<label for="agreeToTerms">I agree to <a href="/tou" target="_blank">Terms of Use</a> and <a href="/privacy-policy" target="_blank">Privacy Policy</a>.</label>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Register</button>
|
||||
</form>
|
||||
|
||||
<div *ngIf="registered">
|
||||
<p>An email was sent to {{email}}. Please click on the link to verify your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
17
src/app/register/register.module.ts
Normal file
17
src/app/register/register.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RegisterPage } from './register';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
RegisterPage
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class RegisterModule { }
|
||||
0
src/app/register/register.scss
Normal file
0
src/app/register/register.scss
Normal file
70
src/app/register/register.ts
Normal file
70
src/app/register/register.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import {
|
||||
FormGroup,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { UserService } from '../core/user.service';
|
||||
import { ConfigService } from '../core/config.service';
|
||||
import { User } from '../shared/user';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
templateUrl: 'register.html'
|
||||
})
|
||||
export class RegisterPage {
|
||||
public registered: boolean = false;
|
||||
private form: FormGroup;
|
||||
private email: string;
|
||||
private error: AppError;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private userService: UserService,
|
||||
private configService: ConfigService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
this.form = fb.group({
|
||||
'firstName': ['', Validators.required],
|
||||
'lastName': ['', Validators.required],
|
||||
'email': ['', Validators.required],
|
||||
'password': ['', Validators.required],
|
||||
'password2': ['', Validators.required],
|
||||
'agreeToTerms': [false, Validators.required]
|
||||
}, {
|
||||
validator: this.passwordMatchValidator
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
let formUser = new User(this.form.value);
|
||||
formUser.id = Util.newGuid();
|
||||
this.log.debug(formUser);
|
||||
|
||||
this.userService.postUser(formUser)
|
||||
.subscribe(
|
||||
user => {
|
||||
this.log.debug(user);
|
||||
this.registered = true;
|
||||
this.email = user.email;
|
||||
},
|
||||
error => {
|
||||
this.log.debug('An error occurred!');
|
||||
this.log.debug(error);
|
||||
this.error = error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
passwordMatchValidator(control: AbstractControl) {
|
||||
if(control.get('password').value === control.get('password2').value) {
|
||||
return null;
|
||||
} else {
|
||||
control.get('password2').setErrors({mismatchedPassword: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/app/reports/balancesheet.html
Normal file
96
src/app/reports/balancesheet.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<h1>Balance Sheet<br>{{date | date:"shortDate"}} (<a [routerLink]="" (click)="toggleShowOptionsForm()">options</a>)</h1>
|
||||
|
||||
<div class="section">
|
||||
<div *ngIf="showOptionsForm" class="card card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Date</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="date" type="date" class="form-control" id="date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="priceSource" class="col-sm-3 col-form-label">Price Source</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="priceSource" formControlName="priceSource">
|
||||
<option value="cost">Cost</option>
|
||||
<option value="price">Nearest In Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error">{{error.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!form.valid">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div *ngIf="assetAccount" class="container-fluid report">
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Assets</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4 *ngIf="priceSource === 'price'">{{+assetAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
|
||||
<h4 *ngIf="priceSource === 'cost'">{{+assetAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let account of assetAccounts" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
|
||||
<span *ngIf="account.children.length">{{account.name}}</span>
|
||||
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
|
||||
{{+account.totalNativeBalancePrice | currencyFormat:org.precision}}
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
|
||||
{{+account.totalNativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Liabilities</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4 *ngIf="priceSource === 'price'">{{-liabilityAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
|
||||
<h4 *ngIf="priceSource === 'cost'">{{-liabilityAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let account of liabilityAccounts" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
|
||||
<span *ngIf="account.children.length">{{account.name}}</span>
|
||||
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
|
||||
{{-account.totalNativeBalancePrice | currencyFormat:org.precision}}
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
|
||||
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Equity</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4 *ngIf="priceSource === 'price'">{{-equityAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
|
||||
<h4 *ngIf="priceSource === 'cost'">{{-equityAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let account of equityAccounts" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
|
||||
<span *ngIf="account.children.length">{{account.name}}</span>
|
||||
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
|
||||
{{-account.totalNativeBalancePrice | currencyFormat:org.precision}}
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
|
||||
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
159
src/app/reports/balancesheet.ts
Normal file
159
src/app/reports/balancesheet.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { ConfigService } from '../core/config.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Account } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { TxListPage } from '../transaction/list';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-balancesheet',
|
||||
templateUrl: 'balancesheet.html',
|
||||
styleUrls: ['./reports.scss']
|
||||
})
|
||||
export class BalanceSheetReport {
|
||||
public org: Org;
|
||||
public date: Date;
|
||||
public assetAccount: Account;
|
||||
public assetAccounts: Account[] = [];
|
||||
public liabilityAccount: Account;
|
||||
public liabilityAccounts: Account[] = [];
|
||||
public equityAccount: Account;
|
||||
public equityAccounts: Account[] = [];
|
||||
public amounts: any = {};
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
public showOptionsForm: boolean = false;
|
||||
private treeSubscription: Subscription;
|
||||
private priceSource: string;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private configService: ConfigService,
|
||||
private sessionService: SessionService) {
|
||||
this.date = new Date();
|
||||
this.priceSource = 'price';
|
||||
|
||||
let reportData = this.configService.get('reportData');
|
||||
|
||||
if(reportData && reportData.balanceSheet) {
|
||||
let reportConfig = reportData.balanceSheet;
|
||||
if(reportConfig.date) {
|
||||
this.date = new Date(reportConfig.date);
|
||||
}
|
||||
|
||||
if(reportConfig.priceSource) {
|
||||
this.priceSource = reportConfig.priceSource;
|
||||
}
|
||||
}
|
||||
|
||||
this.form = fb.group({
|
||||
date: [Util.getLocalDateString(this.date), Validators.required],
|
||||
priceSource: [this.priceSource, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sessionService.setLoading(true);
|
||||
this.org = this.orgService.getCurrentOrg();
|
||||
this.amounts = {};
|
||||
this.assetAccount = null;
|
||||
|
||||
this.treeSubscription = this.accountService.getAccountTreeAtDate(this.date)
|
||||
.subscribe(tree => {
|
||||
this.sessionService.setLoading(false);
|
||||
this.assetAccount = tree.getAccountByName('Assets', 1);
|
||||
this.assetAccounts = tree.getFlattenedAccounts(this.assetAccount);
|
||||
|
||||
this.liabilityAccount = tree.getAccountByName('Liabilities', 1);
|
||||
this.liabilityAccounts = tree.getFlattenedAccounts(this.liabilityAccount);
|
||||
|
||||
this.equityAccount = tree.getAccountByName('Equity', 1);
|
||||
this.equityAccounts = tree.getFlattenedAccounts(this.equityAccount);
|
||||
|
||||
let incomeAccount = tree.getAccountByName('Income', 1);
|
||||
let expenseAccount = tree.getAccountByName('Expenses', 1);
|
||||
|
||||
let retainedEarnings = new Account({
|
||||
id: 'Retained Earnings',
|
||||
name: 'Retained Earnings',
|
||||
depth: 2,
|
||||
children: [null], // hack to fool template into not displaying a link
|
||||
totalNativeBalanceCost: incomeAccount.totalNativeBalanceCost +
|
||||
expenseAccount.totalNativeBalanceCost,
|
||||
totalNativeBalancePrice: incomeAccount.totalNativeBalancePrice +
|
||||
expenseAccount.totalNativeBalancePrice
|
||||
});
|
||||
|
||||
let unrealizedGains = new Account({
|
||||
id: 'Unrealized Gains',
|
||||
name: 'Unrealized Gains',
|
||||
depth: 2,
|
||||
children: [null], // hack to fool template into not displaying a link
|
||||
totalNativeBalanceCost: -(this.assetAccount.totalNativeBalanceCost +
|
||||
this.liabilityAccount.totalNativeBalanceCost +
|
||||
this.equityAccount.totalNativeBalanceCost +
|
||||
retainedEarnings.totalNativeBalanceCost),
|
||||
totalNativeBalancePrice: -(this.assetAccount.totalNativeBalancePrice +
|
||||
this.liabilityAccount.totalNativeBalancePrice +
|
||||
this.equityAccount.totalNativeBalancePrice +
|
||||
retainedEarnings.totalNativeBalancePrice)
|
||||
});
|
||||
|
||||
this.equityAccounts.push(retainedEarnings);
|
||||
this.equityAccounts.push(unrealizedGains);
|
||||
|
||||
// TODO is this modifying a tree that might be used elsewhere?
|
||||
// Not all functions are pure...
|
||||
this.equityAccount.totalNativeBalanceCost = -this.assetAccount.totalNativeBalanceCost
|
||||
- this.liabilityAccount.totalNativeBalanceCost;
|
||||
this.equityAccount.totalNativeBalancePrice = -this.assetAccount.totalNativeBalancePrice
|
||||
- this.liabilityAccount.totalNativeBalancePrice;
|
||||
|
||||
// this.dataService.setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.treeSubscription.unsubscribe();
|
||||
//this.dataService.setLoading(true);
|
||||
this.showOptionsForm = false;
|
||||
this.date = Util.getDateFromLocalDateString(this.form.value.date);
|
||||
this.priceSource = this.form.value.priceSource;
|
||||
|
||||
let reportData = this.configService.get('reportData');
|
||||
|
||||
if(!reportData) {
|
||||
reportData = {};
|
||||
}
|
||||
|
||||
reportData.balanceSheet = {
|
||||
date: this.date,
|
||||
priceSource: this.priceSource
|
||||
}
|
||||
|
||||
this.configService.put('reportData', reportData);
|
||||
|
||||
this.ngOnInit();
|
||||
}
|
||||
|
||||
toggleShowOptionsForm() {
|
||||
this.showOptionsForm = !this.showOptionsForm;
|
||||
}
|
||||
|
||||
}
|
||||
65
src/app/reports/income.html
Normal file
65
src/app/reports/income.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<h1>Income Statement<br>{{startDate | date:"shortDate"}} - {{endDate.getTime() - 1 | date:"shortDate"}} (<a [routerLink]="" (click)="toggleShowDateForm()">edit</a>)</h1>
|
||||
|
||||
<div class="section">
|
||||
<form *ngIf="showDateForm" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">Start Date</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="startDate" type="date" class="form-control" id="startDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-sm-3 col-form-label">End Date</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="endDate" type="date" class="form-control" id="endDate">
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error">{{error.message}}</p>
|
||||
<button class="btn btn-primary" type="submit" [disabled]="!form.valid">Update</button>
|
||||
</form>
|
||||
|
||||
<div *ngIf="incomeAccount" class="container-fluid report">
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Income</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4>{{-incomeAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let account of incomeAccounts" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" *ngIf="account.totalNativeBalanceCost">
|
||||
<span *ngIf="account.children.length">{{account.name}}</span>
|
||||
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="account.totalNativeBalanceCost">
|
||||
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Expenses</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4>{{expenseAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let account of expenseAccounts" [attr.depth]="account.depth">
|
||||
<div class="col-8 name" *ngIf="account.totalNativeBalanceCost">
|
||||
<span *ngIf="account.children.length">{{account.name}}</span>
|
||||
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
|
||||
</div>
|
||||
<div class="col-4 amount" *ngIf="account.totalNativeBalanceCost">
|
||||
{{account.totalNativeBalanceCost | currencyFormat:org.precision}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" depth="1">
|
||||
<div class="col-8">
|
||||
<h4>Net Income</h4>
|
||||
</div>
|
||||
<div class="col-4 amount">
|
||||
<h4>{{-incomeAccount.totalNativeBalanceCost - expenseAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
113
src/app/reports/income.ts
Normal file
113
src/app/reports/income.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { ConfigService } from '../core/config.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import 'rxjs/add/observable/zip';
|
||||
import { Account, AccountTree } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { TxListPage } from '../transaction/list';
|
||||
import {
|
||||
FormGroup,
|
||||
FormControl,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { AppError } from '../shared/error';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-income',
|
||||
templateUrl: 'income.html',
|
||||
styleUrls: ['./reports.scss']
|
||||
})
|
||||
export class IncomeReport {
|
||||
public org: Org;
|
||||
public startDate: Date;
|
||||
public endDate: Date;
|
||||
public incomeAccount: Account;
|
||||
public incomeAccounts: Account[] = [];
|
||||
public expenseAccount: Account;
|
||||
public expenseAccounts: Account[] = [];
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
public showDateForm: boolean = false;
|
||||
private treeSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private accountService: AccountService,
|
||||
private orgService: OrgService,
|
||||
private configService: ConfigService,
|
||||
private sessionService: SessionService) {
|
||||
this.startDate = new Date();
|
||||
this.startDate.setDate(1);
|
||||
this.startDate.setHours(0, 0, 0, 0);
|
||||
this.endDate = new Date(this.startDate);
|
||||
this.endDate.setMonth(this.startDate.getMonth() + 1);
|
||||
|
||||
let reportData = this.configService.get('reportData');
|
||||
|
||||
if(reportData && reportData.income) {
|
||||
let reportConfig = reportData.income;
|
||||
if(reportConfig.startDate) {
|
||||
this.startDate = new Date(reportConfig.startDate);
|
||||
}
|
||||
|
||||
if(reportConfig.endDate) {
|
||||
this.endDate = new Date(reportConfig.endDate);
|
||||
}
|
||||
}
|
||||
|
||||
this.form = fb.group({
|
||||
startDate: [Util.getLocalDateString(this.startDate), Validators.required],
|
||||
endDate: [Util.getLocalDateString(new Date(this.endDate.getTime() - 1)), Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.sessionService.setLoading(true);
|
||||
this.org = this.orgService.getCurrentOrg();
|
||||
|
||||
this.treeSubscription = this.accountService.getAccountTreeWithPeriodBalance(this.startDate, this.endDate)
|
||||
.subscribe(tree => {
|
||||
this.sessionService.setLoading(false);
|
||||
this.incomeAccount = tree.getAccountByName('Income', 1);
|
||||
this.incomeAccounts = tree.getFlattenedAccounts(this.incomeAccount);
|
||||
this.expenseAccount = tree.getAccountByName('Expenses', 1);
|
||||
this.expenseAccounts = tree.getFlattenedAccounts(this.expenseAccount);
|
||||
});
|
||||
}
|
||||
|
||||
toggleShowDateForm() {
|
||||
this.showDateForm = !this.showDateForm;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.treeSubscription.unsubscribe();
|
||||
//this.dataService.setLoading(true);
|
||||
this.showDateForm = false;
|
||||
this.startDate = Util.getDateFromLocalDateString(this.form.value.startDate);
|
||||
this.endDate = Util.getDateFromLocalDateString(this.form.value.endDate);
|
||||
this.endDate.setDate(this.endDate.getDate() + 1);
|
||||
|
||||
let reportData = this.configService.get('reportData');
|
||||
|
||||
if(!reportData) {
|
||||
reportData = {};
|
||||
}
|
||||
|
||||
reportData.income = {
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate
|
||||
}
|
||||
|
||||
this.configService.put('reportData', reportData);
|
||||
|
||||
this.ngOnInit();
|
||||
}
|
||||
}
|
||||
8
src/app/reports/reports.html
Normal file
8
src/app/reports/reports.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<h1>Reports</h1>
|
||||
<div class="section">
|
||||
<ul>
|
||||
<li *ngFor="let report of reports">
|
||||
<a [routerLink]="report.url">{{report.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
25
src/app/reports/reports.module.ts
Normal file
25
src/app/reports/reports.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ReportsPage } from './reports';
|
||||
import { IncomeReport } from './income';
|
||||
import { BalanceSheetReport } from './balancesheet';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ReportsPage,
|
||||
IncomeReport,
|
||||
BalanceSheetReport
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ReactiveFormsModule,
|
||||
AppRoutingModule,
|
||||
SharedModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class ReportsModule { }
|
||||
51
src/app/reports/reports.scss
Normal file
51
src/app/reports/reports.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
.report {
|
||||
h4 {
|
||||
font-size: 1.3rem
|
||||
}
|
||||
.total {
|
||||
font-weight: bold
|
||||
}
|
||||
> .row[depth="1"]:first-child {
|
||||
padding-top: 0px;
|
||||
}
|
||||
.row[depth="1"] {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.row[depth="1"] div:first-child,
|
||||
.row[depth="2"] .name {
|
||||
padding-left: 0px;
|
||||
}
|
||||
.row[depth="3"] .name {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.row[depth="4"] .name {
|
||||
padding-left: 40px;
|
||||
}
|
||||
.row[depth="5"] .name {
|
||||
padding-left: 60px;
|
||||
}
|
||||
.row .name {
|
||||
padding-left: 60px;
|
||||
}
|
||||
.row[depth="1"] div:last-child,
|
||||
.row[depth="2"] .amount {
|
||||
padding-right: 0px
|
||||
}
|
||||
.row[depth="3"] .amount {
|
||||
padding-right: 100px;
|
||||
}
|
||||
.row[depth="4"] .amount {
|
||||
padding-right: 200px;
|
||||
}
|
||||
.row[depth="5"] .amount {
|
||||
padding-right: 300px;
|
||||
}
|
||||
.row .amount {
|
||||
padding-right: 300px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
18
src/app/reports/reports.ts
Normal file
18
src/app/reports/reports.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'page-reports',
|
||||
templateUrl: 'reports.html'
|
||||
})
|
||||
export class ReportsPage {
|
||||
|
||||
reports: Array<{title: string, url: string}>;
|
||||
|
||||
constructor() {
|
||||
this.reports = [
|
||||
{ title: 'Income Statement', url: '/reports/income' },
|
||||
{ title: 'Balance Sheet', url: '/reports/balancesheet'}
|
||||
];
|
||||
}
|
||||
}
|
||||
79
src/app/settings/settings.html
Normal file
79
src/app/settings/settings.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="description">
|
||||
Use the forms below to change your settings.
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>General</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group row">
|
||||
<label for="server" class="col-sm-3 col-form-label">Server</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="server" id="server" type="text" class="form-control" placeholder="Server" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Save</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div *ngIf="sessionService.getUser()">
|
||||
<div class="section">
|
||||
<h2>Change Password</h2>
|
||||
|
||||
<form [formGroup]="changePasswordForm" (ngSubmit)="changePassword()">
|
||||
<div class="form-group row">
|
||||
<label for="password" class="col-sm-3 col-form-label">New Password</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="password" id="password" type="password" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="password2" class="col-sm-3 col-form-label">Repeat Password</label>
|
||||
<div class="col-sm-9">
|
||||
<input formControlName="password2" id="password2" type="password" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="changePasswordForm.controls.password2.errors?.mismatchedPassword">
|
||||
Passwords do not match.
|
||||
</p>
|
||||
<p *ngIf="changePasswordError" class="error">{{changePasswordError.message}}</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!changePasswordForm.valid">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>API Keys</h2>
|
||||
|
||||
<div class="container-fluid">
|
||||
<form *ngFor="let item of keyItems; let i = index" [formGroup]="item.form">
|
||||
<div class="row py-2">
|
||||
<div class="col-5">
|
||||
<input type="hidden" formControlName="id" id="id">
|
||||
{{item.form.value.id}}
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<input type="text" formControlName="label" id="label">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div *ngIf="item.exists && item.form.dirty"><a (click)="updateKey(item)">Update</a></div>
|
||||
<div *ngIf="item.exists"><a (click)="deleteKey(item)">Delete</a></div>
|
||||
<div *ngIf="!item.exists"><a (click)="postKey(item)">Save</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<a (click)="newKey()">Create new API key</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngIf="keyError">
|
||||
<div class="col-12">
|
||||
<p class="error">{{keyError.message}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
src/app/settings/settings.module.ts
Normal file
17
src/app/settings/settings.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { SettingsPage } from './settings';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SettingsPage,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class SettingsModule { }
|
||||
145
src/app/settings/settings.ts
Normal file
145
src/app/settings/settings.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
FormGroup,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { ConfigService } from '../core/config.service';
|
||||
import { ApiKeyService } from '../core/apikey.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { UserService } from '../core/user.service';
|
||||
import { AppError } from '../shared/error';
|
||||
import { ApiKey } from '../shared/apikey';
|
||||
import { Util } from '../shared/util';
|
||||
import { User } from '../shared/user';
|
||||
|
||||
class KeyItem {
|
||||
exists: boolean;
|
||||
form: FormGroup;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
templateUrl: 'settings.html'
|
||||
})
|
||||
export class SettingsPage {
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
private changePasswordForm: FormGroup;
|
||||
private changePasswordError: AppError;
|
||||
private keyItems: KeyItem[];
|
||||
private keyError: AppError;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
public sessionService: SessionService,
|
||||
private userService: UserService,
|
||||
private fb: FormBuilder
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
let server = this.configService.get('server');
|
||||
|
||||
this.form = this.fb.group({
|
||||
'server': [server, Validators.required],
|
||||
});
|
||||
|
||||
if(!this.sessionService.getUser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.changePasswordForm = this.fb.group({
|
||||
'password': [null, Validators.required],
|
||||
'password2': [null, Validators.required]
|
||||
}, {
|
||||
validator: this.passwordMatchValidator
|
||||
});
|
||||
|
||||
this.keyItems = [];
|
||||
|
||||
this.apiKeyService.getApiKeys().subscribe(keys => {
|
||||
keys.forEach(key => {
|
||||
this.keyItems.push({
|
||||
exists: true,
|
||||
form: this.fb.group({
|
||||
'id': [key.id, Validators.required],
|
||||
'label': [key.label, Validators.required]
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.configService.put('server', this.form.value.server);
|
||||
}
|
||||
|
||||
changePassword() {
|
||||
let user = new User({
|
||||
password: this.changePasswordForm.value.password
|
||||
});
|
||||
|
||||
this.userService.putUser(user).subscribe(() => {
|
||||
this.changePasswordError = new AppError('Successfully changed password');
|
||||
}, err => {
|
||||
this.changePasswordError = err;
|
||||
});
|
||||
}
|
||||
|
||||
passwordMatchValidator(control: AbstractControl) {
|
||||
if(control.get('password').value === control.get('password2').value) {
|
||||
return null;
|
||||
} else {
|
||||
control.get('password2').setErrors({mismatchedPassword: true});
|
||||
}
|
||||
}
|
||||
|
||||
newKey() {
|
||||
let key = new ApiKey({
|
||||
id: Util.newGuid(),
|
||||
label: ''
|
||||
});
|
||||
|
||||
this.keyItems.push({
|
||||
exists: false,
|
||||
form: this.fb.group({
|
||||
'id': [key.id, Validators.required],
|
||||
'label': [key.label, Validators.required]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
postKey(item: KeyItem) {
|
||||
let key = new ApiKey(item.form.value);
|
||||
this.apiKeyService.newApiKey(key).subscribe(() => {
|
||||
item.exists = true;
|
||||
item.form.markAsPristine();
|
||||
}, err => {
|
||||
this.keyError = err;
|
||||
})
|
||||
}
|
||||
|
||||
updateKey(item: KeyItem) {
|
||||
let key = new ApiKey(item.form.value);
|
||||
this.apiKeyService.putApiKey(key).subscribe(newKey => {
|
||||
item.form.markAsPristine();
|
||||
}, err => {
|
||||
this.keyError = err;
|
||||
})
|
||||
}
|
||||
|
||||
deleteKey(item: KeyItem) {
|
||||
let key = new ApiKey(item.form.value);
|
||||
this.apiKeyService.deleteApiKey(key.id).subscribe(() => {
|
||||
// remove item from list
|
||||
this.keyItems = this.keyItems.filter(item => {
|
||||
return item.form.value['id'] !== key.id;
|
||||
});
|
||||
}, err => {
|
||||
this.keyError = err;
|
||||
})
|
||||
}
|
||||
}
|
||||
28
src/app/shared/account-balance.pipe.ts
Normal file
28
src/app/shared/account-balance.pipe.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { CurrencyFormatPipe } from './currency-format.pipe';
|
||||
import { Account } from './account';
|
||||
|
||||
@Pipe({name: 'accountBalance'})
|
||||
export class AccountBalancePipe implements PipeTransform {
|
||||
constructor(private currencyFormatPipe: CurrencyFormatPipe) {
|
||||
}
|
||||
|
||||
transform(account: Account, balanceType = 'price'): string {
|
||||
let sign = account.debitBalance ? 1 : -1;
|
||||
|
||||
let nativeBalance = 0;
|
||||
|
||||
switch(balanceType) {
|
||||
case 'cost':
|
||||
nativeBalance = account.totalNativeBalanceCost;
|
||||
break;
|
||||
case 'price':
|
||||
nativeBalance = account.totalNativeBalancePrice;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid balance type ' + balanceType);
|
||||
}
|
||||
|
||||
return this.currencyFormatPipe.transform(sign * nativeBalance, account.orgPrecision, account.orgCurrency);
|
||||
}
|
||||
}
|
||||
21
src/app/shared/account-name.pipe.ts
Normal file
21
src/app/shared/account-name.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({name: 'accountName'})
|
||||
export class AccountNamePipe implements PipeTransform {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
transform(name: string, depth: number): string {
|
||||
let parts = name.split(':');
|
||||
|
||||
let accountString = '';
|
||||
|
||||
if(!depth) {
|
||||
depth = 1;
|
||||
}
|
||||
|
||||
parts = parts.slice(depth - 1, parts.length);
|
||||
|
||||
return parts.join(':');
|
||||
}
|
||||
}
|
||||
168
src/app/shared/account.ts
Normal file
168
src/app/shared/account.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export class AccountApi {
|
||||
id: string;
|
||||
orgId: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
name: string;
|
||||
parent: string;
|
||||
currency: string;
|
||||
precision: number;
|
||||
debitBalance: boolean;
|
||||
balance: number;
|
||||
nativeBalance: number;
|
||||
readOnly: boolean;
|
||||
recentTxCount: number;
|
||||
price: number;
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.orgId = options.orgId;
|
||||
this.inserted = options.inserted ? new Date(options.inserted): null;
|
||||
this.updated = options.updated ? new Date(options.updated): null;
|
||||
this.name = options.name;
|
||||
this.parent = options.parent ? options.parent : '';
|
||||
this.currency = options.currency;
|
||||
this.precision = options.precision ? parseInt(options.precision) : 0;
|
||||
this.debitBalance = options.debitBalance;
|
||||
this.balance = options.balance;
|
||||
this.nativeBalance = options.nativeBalance;
|
||||
this.readOnly = options.readOnly;
|
||||
this.recentTxCount = options.recentTxCount ? parseInt(options.recentTxCount) : 0;
|
||||
this.price = options.price;
|
||||
}
|
||||
}
|
||||
|
||||
export class Account {
|
||||
id: string;
|
||||
orgId: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
name: string;
|
||||
fullName: string;
|
||||
parent: Account;
|
||||
currency: string;
|
||||
precision: number;
|
||||
debitBalance: boolean;
|
||||
balance: number;
|
||||
totalBalance: number;
|
||||
nativeBalanceCost: number;
|
||||
totalNativeBalanceCost: number;
|
||||
nativeBalancePrice: number;
|
||||
totalNativeBalancePrice: number;
|
||||
price: number;
|
||||
orgCurrency: string;
|
||||
orgPrecision: number;
|
||||
readOnly: boolean;
|
||||
depth: number;
|
||||
recentTxCount: number;
|
||||
children: Account[];
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.orgId = options.orgId;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.name = options.name;
|
||||
this.fullName = options.fullName;
|
||||
this.parent = options.parent;
|
||||
this.currency = options.currency;
|
||||
this.precision = options.precision ? parseInt(options.precision) : 0;
|
||||
this.debitBalance = options.debitBalance || false;
|
||||
this.balance = options.balance || 0;
|
||||
this.totalBalance = options.totalBalance || 0;
|
||||
this.nativeBalanceCost = options.nativeBalance || 0;
|
||||
this.totalNativeBalanceCost = options.totalNativeBalanceCost || options.totalNativeBalance || 0;
|
||||
this.nativeBalancePrice = options.nativeBalancePrice || 0;
|
||||
this.totalNativeBalancePrice = options.totalNativeBalancePrice || 0;
|
||||
this.price = options.price || 0;
|
||||
this.orgCurrency = options.orgCurrency;
|
||||
this.orgPrecision = options.orgPrecision ? parseInt(options.orgPrecision) : 0;
|
||||
this.readOnly = options.readOnly;
|
||||
this.depth = options.depth;
|
||||
this.recentTxCount = options.recentTxCount || 0;
|
||||
this.children = options.children || [];
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountTree {
|
||||
accountMap: { [accountId: number]: Account; };
|
||||
rootAccount: Account;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
this.accountMap = options.accountMap;
|
||||
this.rootAccount = options.rootAccount;
|
||||
}
|
||||
|
||||
getFlattenedAccounts(node?: Account): Account[] {
|
||||
if(!node) {
|
||||
node = this.rootAccount;
|
||||
}
|
||||
|
||||
let flattened = [];
|
||||
|
||||
for(let account of node.children) {
|
||||
flattened.push(account);
|
||||
flattened = flattened.concat(this.getFlattenedAccounts(account));
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
getAccountByName (name: string, depth?: number): Account {
|
||||
for(let id in this.accountMap) {
|
||||
let account = this.accountMap[id];
|
||||
|
||||
if(account.name === name) {
|
||||
if(!depth || account.depth === depth) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
accountIsChildOf(account: Account, parent: Account) {
|
||||
for(let child of parent.children) {
|
||||
if(child.id === account.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if(this.accountIsChildOf(account, child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getAccountAtoms(node?: Account): Account[] {
|
||||
if(!node) {
|
||||
node = this.rootAccount;
|
||||
}
|
||||
|
||||
let accounts = [];
|
||||
|
||||
for(let i = 0; i < node.children.length; i++) {
|
||||
let child = node.children[i];
|
||||
if(!child.children.length) {
|
||||
accounts.push(child);
|
||||
} else {
|
||||
accounts = accounts.concat(this.getAccountAtoms(child));
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
getAccountLabel(account: Account, depth: number) {
|
||||
let node = account;
|
||||
|
||||
let accountArray = [account.name];
|
||||
|
||||
while(node.parent.depth >= depth) {
|
||||
node = node.parent;
|
||||
accountArray.unshift(node.name);
|
||||
}
|
||||
|
||||
return accountArray.join(':');
|
||||
}
|
||||
}
|
||||
15
src/app/shared/apikey.ts
Normal file
15
src/app/shared/apikey.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export class ApiKey {
|
||||
id: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
userId: string;
|
||||
label: string;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.userId = options.userId;
|
||||
this.label = options.label;
|
||||
}
|
||||
}
|
||||
13
src/app/shared/config.ts
Normal file
13
src/app/shared/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class Config {
|
||||
server: string;
|
||||
email: string;
|
||||
password: string; // switch to session based auth
|
||||
defaultOrg: string;
|
||||
reportData: any;
|
||||
constructor(options: any = {}) {
|
||||
this.server = options.server || 'https://openaccounting.io:8080/api';
|
||||
this.email = options.email;
|
||||
this.password = options.password;
|
||||
this.defaultOrg = options.defaultOrg;
|
||||
}
|
||||
}
|
||||
27
src/app/shared/currency-format.pipe.ts
Normal file
27
src/app/shared/currency-format.pipe.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
|
||||
@Pipe({name: 'currencyFormat'})
|
||||
export class CurrencyFormatPipe implements PipeTransform {
|
||||
constructor(private decimalPipe: DecimalPipe) {
|
||||
}
|
||||
|
||||
transform(amount: number, precision: number, currency = 'USD'): string {
|
||||
if(amount === null || amount === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let prefix = amount < 0 ? '-' : '';
|
||||
|
||||
if(currency === 'USD') {
|
||||
prefix += '$';
|
||||
}
|
||||
|
||||
let minDigits = Math.min(2, precision);
|
||||
|
||||
return prefix +
|
||||
this.decimalPipe.transform(
|
||||
Math.abs(amount) / Math.pow(10, precision),
|
||||
'1.' + minDigits + '-' + precision);
|
||||
}
|
||||
}
|
||||
8
src/app/shared/error.ts
Normal file
8
src/app/shared/error.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class AppError extends Error {
|
||||
constructor(m: string, public code?: number) {
|
||||
super(m);
|
||||
|
||||
// Set the prototype explicitly.
|
||||
Object.setPrototypeOf(this, AppError.prototype);
|
||||
}
|
||||
}
|
||||
17
src/app/shared/invite.ts
Normal file
17
src/app/shared/invite.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class Invite {
|
||||
id: string;
|
||||
orgId: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
email: string;
|
||||
accepted: boolean;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.orgId = options.orgId;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.email = options.email;
|
||||
this.accepted = options.accepted;
|
||||
}
|
||||
}
|
||||
19
src/app/shared/message.ts
Normal file
19
src/app/shared/message.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export class Message {
|
||||
version: string;
|
||||
sequenceNumber: number;
|
||||
type: string;
|
||||
action: string;
|
||||
data: any;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
this.version = options.version;
|
||||
this.sequenceNumber = options.sequenceNumber;
|
||||
this.type = options.type;
|
||||
this.action = options.action;
|
||||
this.data = options.data;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
}
|
||||
16
src/app/shared/org.ts
Normal file
16
src/app/shared/org.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export class Org {
|
||||
id: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
name: string;
|
||||
currency: string;
|
||||
precision: number;
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.name = options.name;
|
||||
this.currency = options.currency;
|
||||
this.precision = options.precision && parseInt(options.precision);
|
||||
}
|
||||
}
|
||||
17
src/app/shared/price.ts
Normal file
17
src/app/shared/price.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class Price {
|
||||
id: string;
|
||||
currency: string;
|
||||
date: Date;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
price: number;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.currency = options.currency;
|
||||
this.date = options.date ? new Date(options.date) : null;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.price = options.price;
|
||||
}
|
||||
}
|
||||
6
src/app/shared/session-options.ts
Normal file
6
src/app/shared/session-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class SessionOptions {
|
||||
createDefaultAccounts: boolean;
|
||||
constructor(options: any = {}) {
|
||||
this.createDefaultAccounts = options.createDefaultAccounts;
|
||||
}
|
||||
};
|
||||
13
src/app/shared/shared.module.ts
Normal file
13
src/app/shared/shared.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { CurrencyFormatPipe } from './currency-format.pipe';
|
||||
import { AccountNamePipe } from './account-name.pipe';
|
||||
import { AccountBalancePipe } from './account-balance.pipe';
|
||||
|
||||
@NgModule({
|
||||
imports: [],
|
||||
declarations: [CurrencyFormatPipe, AccountNamePipe, AccountBalancePipe],
|
||||
exports: [CurrencyFormatPipe, AccountNamePipe, AccountBalancePipe],
|
||||
providers: [DecimalPipe, CurrencyFormatPipe]
|
||||
})
|
||||
export class SharedModule { }
|
||||
49
src/app/shared/transaction.ts
Normal file
49
src/app/shared/transaction.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export class Transaction {
|
||||
id: string;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
description: string;
|
||||
data: any;
|
||||
deleted: boolean;
|
||||
splits: Split[];
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id;
|
||||
this.orgId = options.id;
|
||||
this.userId = options.id;
|
||||
this.date = options.date ? new Date(options.date) : null;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.description = options.description;
|
||||
this.data = options.data;
|
||||
this.deleted = options.deleted;
|
||||
this.splits = options.splits ? options.splits.map(split => new Split(split)) : [];
|
||||
}
|
||||
|
||||
getData(): any {
|
||||
try {
|
||||
return JSON.parse(this.data);
|
||||
} catch(e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
setData(data: any) {
|
||||
this.data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// constructor(init?:Partial<Transaction>) {
|
||||
// Object.assign(this, init);
|
||||
// }
|
||||
}
|
||||
|
||||
export class Split {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
nativeAmount: number;
|
||||
constructor(init?:Partial<Split>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
22
src/app/shared/user.ts
Normal file
22
src/app/shared/user.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export class User {
|
||||
id: string;
|
||||
inserted: Date;
|
||||
updated: Date;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
agreeToTerms: boolean;
|
||||
emailVerified: boolean;
|
||||
constructor(options: any = {}) {
|
||||
this.id = options.id || this.id;
|
||||
this.inserted = options.inserted ? new Date(options.inserted) : null;
|
||||
this.updated = options.updated ? new Date(options.updated) : null;
|
||||
this.firstName = options.firstName || this.firstName;
|
||||
this.lastName = options.lastName || this.lastName;
|
||||
this.email = options.email || this.email;
|
||||
this.password = options.password || this.password;
|
||||
this.agreeToTerms = options.agreeToTerms || false;
|
||||
this.emailVerified = options.emailVerified || this.emailVerified;
|
||||
}
|
||||
}
|
||||
37
src/app/shared/util.ts
Normal file
37
src/app/shared/util.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export class Util {
|
||||
static getLocalDateString(input: Date) {
|
||||
let year = input.getFullYear().toString();
|
||||
let month = (input.getMonth() + 1).toString();
|
||||
let date = input.getDate().toString();
|
||||
|
||||
if(month.length < 2) {
|
||||
month = '0' + month;
|
||||
}
|
||||
|
||||
if(date.length < 2) {
|
||||
date = '0' + date;
|
||||
}
|
||||
|
||||
return year + '-' + month + '-' + date;
|
||||
}
|
||||
|
||||
static getDateFromLocalDateString(input: string) {
|
||||
let parts = input.split('-');
|
||||
let date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setFullYear(parseInt(parts[0]));
|
||||
date.setMonth(parseInt(parts[1]) - 1);
|
||||
date.setDate(parseInt(parts[2]));
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
static newGuid() {
|
||||
let arr = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(arr);
|
||||
|
||||
return Array.prototype.map.call(arr, val => {
|
||||
return ('00' + val.toString(16)).slice(-2);
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
86
src/app/transaction/advancededit.html
Normal file
86
src/app/transaction/advancededit.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Transaction</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form [formGroup]="form">
|
||||
<div class="form-group row">
|
||||
<label for="date" class="col-sm-4 col-form-label">Date</label>
|
||||
<div class="col-sm-8">
|
||||
<input formControlName="date" id="date" type="text" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="description" class="col-sm-4 col-form-label">Description</label>
|
||||
<div class="col-sm-8">
|
||||
<input formControlName="description" id="description" type="text" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
Account
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
Debit
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
Credit
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
</div>
|
||||
</div>
|
||||
<div *ngFor="let split of getSplitControls(); let i=index" [formGroup]="split" class="splits">
|
||||
<div *ngIf="debitVisible(i) || creditVisible(i)" class="row">
|
||||
<div class="col-sm-3"></div>
|
||||
<div *ngIf="!debitVisible(i)" class="col-sm-4"></div>
|
||||
<div *ngIf="debitVisible(i)" class="col-sm-2">{{getCurrency(split.value.accountId)}}</div>
|
||||
<div *ngIf="debitVisible(i)" class="col-sm-2">{{org.currency}}</div>
|
||||
<div *ngIf="!creditVisible(i)" class="col-sm-4"></div>
|
||||
<div *ngIf="creditVisible(i)" class="col-sm-2">{{org.currency}}</div>
|
||||
<div *ngIf="creditVisible(i)" class="col-sm-2">{{getCurrency(split.value.accountId)}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-3 account">
|
||||
<select class="form-control" formControlName="accountId">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div *ngIf="!debitVisible(i)" class="col-sm-4">
|
||||
<button *ngIf="!creditVisible(i) || !split.value.credit" type="button" class="btn btn-primary" (click)="showDebit(i)">Debit</button>
|
||||
</div>
|
||||
<div *ngIf="debitVisible(i)" class="col-sm-2">
|
||||
<input type="text" class="form-control" formControlName="debit" />
|
||||
</div>
|
||||
<div *ngIf="debitVisible(i)" class="col-sm-2">
|
||||
<input type="text" class="form-control" formControlName="debitNative" />
|
||||
</div>
|
||||
<div *ngIf="!creditVisible(i)" class="col-sm-4">
|
||||
<button *ngIf="!debitVisible(i) || !split.value.debit" type="button" class="btn btn-primary" (click)="showCredit(i)">Credit</button>
|
||||
</div>
|
||||
<div *ngIf="creditVisible(i)" class="col-sm-2">
|
||||
<input type="text" class="form-control" formControlName="creditNative" />
|
||||
</div>
|
||||
<div *ngIf="creditVisible(i)" class="col-sm-2">
|
||||
<input type="text" class="form-control" formControlName="credit" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<a (click)="deleteSplit(i)">X</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12 p-3">
|
||||
<a (click)="addSplit()">Add Split</a>
|
||||
</div>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="submit()">Save</button>
|
||||
</div>
|
||||
9
src/app/transaction/advancededit.scss
Normal file
9
src/app/transaction/advancededit.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
/*.account {
|
||||
overflow: hidden;
|
||||
direction: rtl;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}*/
|
||||
.splits .row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
245
src/app/transaction/advancededit.ts
Normal file
245
src/app/transaction/advancededit.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TxItem } from './txitem';
|
||||
import { Transaction, Split } from '../shared/transaction';
|
||||
import { Account, AccountTree } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { AppError } from '../shared/error';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormArray,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { Util } from '../shared/util';
|
||||
import { OrgService } from '../core/org.service';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
|
||||
@Component({
|
||||
selector: 'advancededit',
|
||||
templateUrl: './advancededit.html',
|
||||
styleUrls: ['./advancededit.scss']
|
||||
})
|
||||
export class AdvancedEdit {
|
||||
public form: FormGroup;
|
||||
public error: AppError;
|
||||
private item: TxItem;
|
||||
private accountTree: AccountTree;
|
||||
private selectAccounts: Account[];
|
||||
private org: Org;
|
||||
private visibleDebits: any = {};
|
||||
private visibleCredits: any = {};
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private log: Logger,
|
||||
private fb: FormBuilder,
|
||||
private orgService: OrgService,
|
||||
private txService: TransactionService
|
||||
) {}
|
||||
|
||||
setData(item: TxItem, accountTree: AccountTree) {
|
||||
this.item = item;
|
||||
this.accountTree = accountTree;
|
||||
this.selectAccounts = accountTree.getFlattenedAccounts().filter(account => {
|
||||
return !account.children.length;
|
||||
});
|
||||
|
||||
this.org = this.orgService.getCurrentOrg();
|
||||
|
||||
let dateString = Util.getLocalDateString(item.tx.date);
|
||||
|
||||
this.form = new FormGroup({
|
||||
date: new FormControl(dateString),
|
||||
description: new FormControl(item.tx.description),
|
||||
splits: this.fb.array([])
|
||||
});
|
||||
|
||||
let orgPrecision = this.org.precision;
|
||||
|
||||
let splits = this.form.get('splits') as FormArray;
|
||||
for(let split of item.tx.splits) {
|
||||
let precision = orgPrecision;
|
||||
let account = this.accountTree.accountMap[split.accountId];
|
||||
|
||||
if(account) {
|
||||
precision = account.precision;
|
||||
}
|
||||
|
||||
let control = new FormGroup({
|
||||
accountId: new FormControl(split.accountId),
|
||||
debit: new FormControl(
|
||||
split.amount >= 0 ? split.amount / Math.pow(10, precision) : null
|
||||
),
|
||||
credit: new FormControl(
|
||||
split.amount < 0 ? -split.amount / Math.pow(10, precision) : null
|
||||
),
|
||||
debitNative: new FormControl(
|
||||
split.nativeAmount >= 0 ? split.nativeAmount / Math.pow(10, orgPrecision) : null
|
||||
),
|
||||
creditNative: new FormControl(
|
||||
split.nativeAmount < 0 ? -split.nativeAmount / Math.pow(10, orgPrecision) : null
|
||||
)
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
// control.valueChanges.subscribe(val => {
|
||||
// this.solveEquations(item);
|
||||
// this.fillEmptySplit(item);
|
||||
// });
|
||||
|
||||
splits.push(control);
|
||||
|
||||
console.log(splits);
|
||||
|
||||
//this.fillEmptySplit(item);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrency(accountId: string) {
|
||||
let account = this.accountTree.accountMap[accountId];
|
||||
return account ? account.currency : '';
|
||||
}
|
||||
|
||||
submit() {
|
||||
console.log('submit');
|
||||
console.log(this.form.value);
|
||||
|
||||
this.error = null;
|
||||
|
||||
let date = this.item.tx.id ? this.item.tx.date : new Date();
|
||||
let formDate = Util.getDateFromLocalDateString(this.form.value.date);
|
||||
|
||||
if(formDate.getTime()) {
|
||||
// make the time be at the very end of the day
|
||||
formDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
let sameDay = formDate.getFullYear() === date.getFullYear() &&
|
||||
formDate.getMonth() === date.getMonth() &&
|
||||
formDate.getDate() === date.getDate();
|
||||
|
||||
if(formDate.getTime() && !sameDay) {
|
||||
date = formDate;
|
||||
}
|
||||
|
||||
let tx = new Transaction({
|
||||
id: this.item.tx.id,
|
||||
date: date,
|
||||
description: this.form.value.description,
|
||||
splits: []
|
||||
});
|
||||
|
||||
for(let i = 0; i < this.form.value.splits.length; i++) {
|
||||
let split = this.form.value.splits[i];
|
||||
let account = this.accountTree.accountMap[split.accountId];
|
||||
|
||||
if(!account) {
|
||||
this.error = new AppError('Invalid account');
|
||||
return;
|
||||
}
|
||||
|
||||
let amount = split.debit ? parseFloat(split.debit) : -parseFloat(split.credit);
|
||||
amount = Math.round(amount * Math.pow(10, account.precision));
|
||||
|
||||
let nativeAmount = split.debitNative ? parseFloat(split.debitNative) : -parseFloat(split.creditNative);
|
||||
nativeAmount = Math.round(nativeAmount * Math.pow(10, this.org.precision))
|
||||
|
||||
tx.splits.push(new Split({
|
||||
accountId: split.accountId,
|
||||
amount: amount,
|
||||
nativeAmount: nativeAmount
|
||||
}));
|
||||
}
|
||||
|
||||
this.log.debug(tx);
|
||||
|
||||
if(tx.id) {
|
||||
// update tx
|
||||
let oldId = tx.id;
|
||||
tx.id = Util.newGuid();
|
||||
|
||||
this.txService.putTransaction(oldId, tx)
|
||||
.subscribe(tx => {
|
||||
this.activeModal.close();
|
||||
|
||||
}, error => {
|
||||
this.error = error;
|
||||
});
|
||||
} else {
|
||||
// new tx
|
||||
|
||||
tx.id = Util.newGuid();
|
||||
this.txService.newTransaction(tx)
|
||||
.subscribe(tx => {
|
||||
this.activeModal.close();
|
||||
|
||||
}, error => {
|
||||
this.error = error;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
addSplit() {
|
||||
this.log.debug('add split');
|
||||
|
||||
let splits = this.form.get('splits') as FormArray;
|
||||
|
||||
let control = new FormGroup({
|
||||
accountId: new FormControl(),
|
||||
debit: new FormControl(),
|
||||
credit: new FormControl(),
|
||||
debitNative: new FormControl(),
|
||||
creditNative: new FormControl()
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
// control.valueChanges.subscribe(val => {
|
||||
// this.solveEquations(item);
|
||||
// this.fillEmptySplit(item);
|
||||
// });
|
||||
splits.push(control);
|
||||
|
||||
// this.fillEmptySplit(item);
|
||||
}
|
||||
|
||||
deleteSplit(index) {
|
||||
this.log.debug('delete split');
|
||||
|
||||
let splits = this.form.get('splits') as FormArray;
|
||||
|
||||
splits.removeAt(index);
|
||||
this.visibleDebits = {};
|
||||
this.visibleCredits = {};
|
||||
|
||||
}
|
||||
|
||||
getSplitControls(): AbstractControl[] {
|
||||
return (this.form.get('splits') as FormArray).controls;
|
||||
}
|
||||
|
||||
debitVisible(index: number) {
|
||||
let splits = this.getSplitControls();
|
||||
|
||||
return this.visibleDebits[index] || splits[index].value.debit;
|
||||
}
|
||||
|
||||
creditVisible(index: number) {
|
||||
let splits = this.getSplitControls();
|
||||
|
||||
return this.visibleCredits[index] || splits[index].value.credit;
|
||||
}
|
||||
|
||||
showDebit(index: number) {
|
||||
this.visibleDebits[index] = true;
|
||||
this.visibleCredits[index] = false;
|
||||
}
|
||||
|
||||
showCredit(index: number) {
|
||||
this.visibleCredits[index] = true;
|
||||
this.visibleDebits[index] = false;
|
||||
|
||||
}
|
||||
}
|
||||
7
src/app/transaction/autocomplete.html
Normal file
7
src/app/transaction/autocomplete.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="autocomplete" [ngClass]="{visible: visible}">
|
||||
<div class="inner">
|
||||
<div class="suggestion" *ngFor="let tx of txs$ | async">
|
||||
<a (click)="click(tx)">{{tx.description}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
22
src/app/transaction/autocomplete.scss
Normal file
22
src/app/transaction/autocomplete.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.autocomplete {
|
||||
display: none;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #d4d4d4;
|
||||
border-bottom: none;
|
||||
}
|
||||
.suggestion {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete.visible {
|
||||
display: block;
|
||||
}
|
||||
68
src/app/transaction/autocomplete.ts
Normal file
68
src/app/transaction/autocomplete.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { TxItem } from './txitem';
|
||||
import { EmptyObservable } from 'rxjs/observable/EmptyObservable';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
@Component({
|
||||
selector: 'tx-autocomplete',
|
||||
templateUrl: 'autocomplete.html',
|
||||
styleUrls: ['./autocomplete.scss']
|
||||
})
|
||||
export class Autocomplete {
|
||||
@Input() item: TxItem;
|
||||
@Input() accountId: string;
|
||||
@Output() tx = new EventEmitter<Transaction>();
|
||||
public visible: boolean;
|
||||
public txs$: Observable<Transaction[]>;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private txService: TransactionService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.txs$ = this.item.edit$
|
||||
.switchMap(() => {
|
||||
let control = this.item.form.get('description');
|
||||
return this.item.form.get('description').valueChanges;
|
||||
})
|
||||
.debounceTime(100)
|
||||
.filter(description => {
|
||||
if(!description || description.length < 3) {
|
||||
this.visible = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.switchMap(description => {
|
||||
this.log.debug('autocomplete', description);
|
||||
|
||||
let options = {limit: 5, descriptionStartsWith: description};
|
||||
return this.txService.getTransactionsByAccount(this.accountId, options);
|
||||
}).map(txs => {
|
||||
let txMap = {};
|
||||
return txs.filter(tx => {
|
||||
if(!txMap[tx.description]) {
|
||||
txMap[tx.description] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}).do((txs) => {
|
||||
if(txs.length) {
|
||||
this.visible = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
click(tx: Transaction) {
|
||||
this.tx.emit(tx);
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
}
|
||||
6
src/app/transaction/breadcrumbs.html
Normal file
6
src/app/transaction/breadcrumbs.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="breadcrumbs">
|
||||
<span *ngFor="let account of accountCrumbs; let i = index">
|
||||
<span><a routerLink="/accounts">{{account.name}}</a></span>
|
||||
<span *ngIf="i < accountCrumbs.length - 1"> > </span>
|
||||
</span>
|
||||
</div>
|
||||
0
src/app/transaction/breadcrumbs.scss
Normal file
0
src/app/transaction/breadcrumbs.scss
Normal file
25
src/app/transaction/breadcrumbs.ts
Normal file
25
src/app/transaction/breadcrumbs.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Account } from '../shared/account';
|
||||
|
||||
@Component({
|
||||
selector: 'breadcrumbs',
|
||||
templateUrl: 'breadcrumbs.html',
|
||||
styleUrls: ['./breadcrumbs.scss']
|
||||
})
|
||||
export class Breadcrumbs {
|
||||
@Input() account: Account;
|
||||
public accountCrumbs: Account[];
|
||||
|
||||
constructor() {
|
||||
this.accountCrumbs = [];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
let currentAccount = this.account;
|
||||
while(currentAccount && currentAccount.depth > 0) {
|
||||
this.accountCrumbs.unshift(currentAccount);
|
||||
currentAccount = currentAccount.parent;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
124
src/app/transaction/list.html
Normal file
124
src/app/transaction/list.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<h1 *ngIf="account">{{account.name | slice:0:30}}</h1>
|
||||
|
||||
<div class="section">
|
||||
<div *ngIf="account" class="mb-2">
|
||||
<breadcrumbs [account]="account"></breadcrumbs>
|
||||
</div>
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="header">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col custom-3">
|
||||
<span>Date</span>
|
||||
</div>
|
||||
<div class="col custom-7">
|
||||
<span>Description</span>
|
||||
</div>
|
||||
<div class="col custom-5">
|
||||
<span>Transfer</span>
|
||||
</div>
|
||||
<div class="col custom-3">
|
||||
<span>Debit</span>
|
||||
</div>
|
||||
<div class="col custom-3">
|
||||
<span>Credit</span>
|
||||
</div>
|
||||
<div class="col custom-3">
|
||||
<span>Balance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" #body id="mybody" (scroll)="onScroll()">
|
||||
<div class="container-fluid">
|
||||
<form [id]="'form' + item.tx.id + item.activeSplitIndex" [formGroup]="item.form" *ngFor="let item of items; let i = index">
|
||||
<div class="row" (click)="editTransaction(item, $event)" [ngClass]="{odd: !(i % 2), editing: item.editing}">
|
||||
<div class="col custom-3 date">
|
||||
<span *ngIf="!item.editing" class="date">{{item.tx.date | date:"M/d/y"}}</span>
|
||||
<input *ngIf="item.editing" type="date" formControlName="date" placeholder="Date" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
|
||||
</div>
|
||||
<div class="col custom-7 description">
|
||||
<div *ngIf="!item.editing">{{item.tx.description}}</div>
|
||||
<input *ngIf="item.editing" type="text" formControlName="description" placeholder="Description" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
|
||||
<tx-autocomplete [item]="item" [accountId]="accountId" (tx)="autocomplete(item, $event)"></tx-autocomplete>
|
||||
</div>
|
||||
<div class="col custom-5 transfer">
|
||||
<span *ngIf="!item.editing" class="transfer">{{getTransferString(item) | slice:0:50}}</span>
|
||||
<select *ngIf="item.editing" class="form-control" formControlName="accountId" [attr.disabled]="item.showSplits ? '' : null" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName | slice:0:50}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col custom-3 debit">
|
||||
<span *ngIf="!item.editing" class="debit">{{getDebit(item) | currencyFormat:account.precision:account.currency}}</span>
|
||||
<input *ngIf="item.editing" type="text" formControlName="debit" placeholder="Debit" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
|
||||
</div>
|
||||
<div class="col custom-3 credit">
|
||||
<span *ngIf="!item.editing" class="credit">{{getCredit(item) | currencyFormat:account.precision:account.currency}}</span>
|
||||
<input *ngIf="item.editing" type="text" formControlName="credit" placeholder="Credit" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
|
||||
</div>
|
||||
<div class="col custom-3 balance" [ngClass]="{'negative': item.balance < 0}">
|
||||
<span *ngIf="!item.editing" class="balance">{{item.balance | currencyFormat:account.precision:account.currency}}</span>
|
||||
|
||||
<div *ngIf="item.editing" ngbDropdown class="d-inline-block">
|
||||
<button class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>Action</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||
<button class="dropdown-item" (click)="addSplit(item)">Split</button>
|
||||
<button class="dropdown-item" (click)="advancedEdit(item)">Adv. Edit</button>
|
||||
<button *ngIf="item.tx.id" class="dropdown-item" (click)="deleteTransaction(item)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <a *ngIf="item.editing" [routerLink]="" (click)="addSplit(item)" (mousedown)="preventBlur(item)">Split</a><br/>
|
||||
<a *ngIf="item.editing" [routerLink]="" (click)="advancedEdit(item)" (mousedown)="preventBlur(item)">Advanced Edit</a><br/>
|
||||
<a *ngIf="item.editing && item.tx.id" [routerLink]="" (click)="deleteTransaction(item)" (mousedown)="preventBlur(item)">Delete</a> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" *ngFor="let split of item.form.get('splits').controls; let i=index" [formGroup]="split">
|
||||
<div class="col custom-3">
|
||||
</div>
|
||||
<div class="col custom-7 add-split">
|
||||
<a [routerLink]="" (click)="deleteSplit(item, i)" (mousedown)="preventBlur(item)">Remove Split</a>
|
||||
</div>
|
||||
<div class="col custom-5">
|
||||
<select class="form-control" formControlName="accountId" (keyup.enter)="onEnter(item, $event)">
|
||||
<option *ngFor="let account of selectAccounts" [value]="account.id">
|
||||
{{account.fullName}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col custom-3">
|
||||
<input type="text" formControlName="debit" placeholder="Debit" class="form-control" (keyup.enter)="onEnter(item, $event)"/>
|
||||
</div>
|
||||
<div class="col custom-3">
|
||||
<input type="text" formControlName="credit" placeholder="Credit" class="form-control" (keyup.enter)="onEnter(item, $event)"/>
|
||||
</div>
|
||||
<div class="col custom-3 add-split">
|
||||
<a *ngIf="i === item.form.get('splits').controls.length - 1" [routerLink]="" (click)="addSplit(item)" (mousedown)="preventBlur(item)">Add Split</a>
|
||||
<!-- <button type="submit">hidden submit</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p *ngIf="error" class="error">{{error.message}}</p>
|
||||
</div>
|
||||
|
||||
<ng-template #confirmDeleteModal let-c="close" let-d="dismiss">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Confirm delete</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="d()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this transaction?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="d()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="c()">Delete</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
76
src/app/transaction/list.scss
Normal file
76
src/app/transaction/list.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
@import '../../sass/variables';
|
||||
|
||||
.row > div {
|
||||
border-bottom: 1px solid #bdd7ef;
|
||||
}
|
||||
.header {
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.row.odd {
|
||||
background-color: #e4f6ff;
|
||||
}
|
||||
.negative {
|
||||
color: $negative;
|
||||
}
|
||||
|
||||
.description > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.transfer {
|
||||
overflow: hidden;
|
||||
direction: rtl;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editing .transfer {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.custom-3 {
|
||||
flex: 0 0 12.5%;
|
||||
max-width: 12.5%;
|
||||
}
|
||||
|
||||
.custom-5 {
|
||||
flex: 0 0 20.8333%;
|
||||
max-width: 20.8333%;
|
||||
}
|
||||
|
||||
.custom-7 {
|
||||
flex: 0 0 29.1666%;
|
||||
max-width: 29.1666%;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: .2rem .2rem;
|
||||
}
|
||||
|
||||
.add-split {
|
||||
padding-top: .4rem;
|
||||
padding-bottom: .4rem;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="date"] {
|
||||
padding: 5px 0px;
|
||||
font-size: 0.75rem
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-inner-spin-button,
|
||||
input[type="date"]::-webkit-clear-button {
|
||||
display: none
|
||||
}
|
||||
862
src/app/transaction/list.ts
Normal file
862
src/app/transaction/list.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import { Component, Input, OnInit, ViewChild, ElementRef, AfterViewChecked, Renderer } from '@angular/core';
|
||||
import { Logger } from '../core/logger';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TransactionService } from '../core/transaction.service';
|
||||
import { AccountService } from '../core/account.service';
|
||||
import { Account, AccountTree } from '../shared/account';
|
||||
import { Transaction, Split} from '../shared/transaction';
|
||||
import { AppError } from '../shared/error';
|
||||
//import { EditTxPage } from './edit';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormArray,
|
||||
Validators,
|
||||
FormBuilder,
|
||||
AbstractControl
|
||||
} from '@angular/forms';
|
||||
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Util } from '../shared/util';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/operator/mergeMap';
|
||||
import { AdvancedEdit } from './advancededit';
|
||||
import { TxItem } from './txitem';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-txlist',
|
||||
templateUrl: 'list.html',
|
||||
styleUrls: ['./list.scss']
|
||||
})
|
||||
export class TxListPage implements OnInit, AfterViewChecked {
|
||||
@ViewChild('body') body: ElementRef;
|
||||
@ViewChild('confirmDeleteModal') confirmDeleteModal: ElementRef;
|
||||
public account: Account;
|
||||
public items: TxItem[];
|
||||
public error: AppError;
|
||||
private accountId: string;
|
||||
private accountTree: AccountTree;
|
||||
private balance: number;
|
||||
private splits: any[];
|
||||
private selectAccounts: Account[];
|
||||
private needsScroll: boolean;
|
||||
private needsLittleScroll: boolean;
|
||||
private scrollLastHeight: number;
|
||||
private limit: number;
|
||||
private skip: number;
|
||||
private historyFinished: boolean;
|
||||
private fetching: boolean;
|
||||
private date: Date;
|
||||
private scrollSubject: Subject<any>;
|
||||
private hasScrolled: boolean;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private route: ActivatedRoute,
|
||||
private txService: TransactionService,
|
||||
private accountService: AccountService,
|
||||
private fb: FormBuilder,
|
||||
private renderer: Renderer,
|
||||
private modalService: NgbModal
|
||||
) {
|
||||
this.items = [];
|
||||
this.limit = 50;
|
||||
this.historyFinished = false;
|
||||
this.fetching = false;
|
||||
this.scrollSubject = new Subject<any>();
|
||||
this.hasScrolled = false;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.accountId = this.route.snapshot.paramMap.get('id'); //+this.route.snapshot.paramMap.get('id');
|
||||
|
||||
this.accountService.getAccountTree().subscribe(tree => {
|
||||
this.account = tree.accountMap[this.accountId];
|
||||
this.selectAccounts = tree.getFlattenedAccounts().filter(account => {
|
||||
return !account.children.length;
|
||||
});
|
||||
|
||||
if(!this.accountTree) {
|
||||
this.accountTree = tree;
|
||||
|
||||
this.skip = 0;
|
||||
this.date = new Date();
|
||||
|
||||
let newTx = new Transaction({
|
||||
date: new Date(),
|
||||
splits: []
|
||||
});
|
||||
|
||||
newTx.date.setHours(23, 59, 59, 999);
|
||||
|
||||
newTx.splits.push(new Split({
|
||||
accountId: this.account.id
|
||||
}));
|
||||
newTx.splits.push(new Split());
|
||||
|
||||
this.appendTransaction(newTx);
|
||||
|
||||
let options = {limit: this.limit, beforeInserted: this.date.getTime()};
|
||||
let latestTxs$ = this.txService.getTransactionsByAccount(this.accountId, options).take(1);
|
||||
let newTxs$ = this.txService.getNewTransactionsByAccount(this.accountId);
|
||||
let deletedTxs$ = this.txService.getDeletedTransactionsByAccount(this.accountId);
|
||||
|
||||
latestTxs$.mergeMap(txs => txs).concat(newTxs$)
|
||||
.subscribe(tx => {
|
||||
// insert tx into list
|
||||
this.addTransaction(tx);
|
||||
});
|
||||
|
||||
deletedTxs$.subscribe(tx => {
|
||||
this.removeTransaction(tx);
|
||||
// remove tx from list
|
||||
});
|
||||
}
|
||||
|
||||
this.accountTree = tree;
|
||||
this.updateBalances();
|
||||
});
|
||||
|
||||
this.scrollSubject.debounceTime(100).subscribe(obj => {
|
||||
if(obj.percent < 0.2 && !this.fetching && !this.historyFinished) {
|
||||
this.fetchMoreTransactions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchMoreTransactions() {
|
||||
this.fetching = true;
|
||||
this.log.debug('Fetching ' + this.limit + ' more transactions');
|
||||
this.skip += this.limit;
|
||||
let options = {limit: this.limit, skip: this.skip, beforeInserted: this.date.getTime()};
|
||||
this.txService.getTransactionsByAccount(this.accountId, options).subscribe(txs => {
|
||||
txs.forEach(tx => {
|
||||
this.addTransaction(tx);
|
||||
});
|
||||
|
||||
if(txs.length < this.limit) {
|
||||
this.historyFinished = true;
|
||||
}
|
||||
|
||||
this.fetching = false;
|
||||
this.needsScroll = false;
|
||||
this.needsLittleScroll = false;
|
||||
this.scrollLastHeight = this.body.nativeElement.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
addTransaction(tx: Transaction) {
|
||||
this.insertTransaction(tx);
|
||||
// it should only scroll to bottom if the user has not scrolled yet
|
||||
if(!this.hasScrolled) {
|
||||
this.needsScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
removeTransaction(tx: Transaction) {
|
||||
this.log.debug('remove tx');
|
||||
this.log.debug(tx);
|
||||
|
||||
for(let i = 0; i < this.items.length; i++) {
|
||||
if(this.items[i].tx.id === tx.id) {
|
||||
this.items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.sortItems();
|
||||
this.updateBalances();
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
if(this.needsLittleScroll) {
|
||||
this.scrollALittle();
|
||||
this.needsLittleScroll = false;
|
||||
}
|
||||
|
||||
let lastItemEditing = this.items.length && this.items[this.items.length - 1].editing;
|
||||
|
||||
if(this.needsScroll || lastItemEditing) {
|
||||
this.scrollToBottom();
|
||||
this.needsScroll = false;
|
||||
}
|
||||
|
||||
if(this.scrollLastHeight) {
|
||||
this.scrollDiffHeight();
|
||||
this.scrollLastHeight = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
this.hasScrolled = true;
|
||||
let element = this.body.nativeElement;
|
||||
this.scrollSubject.next({
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
percent: element.scrollTop / (element.scrollHeight - element.clientHeight)
|
||||
});
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
let element = this.body.nativeElement;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
scrollALittle() {
|
||||
let element = this.body.nativeElement;
|
||||
element.scrollTop += 50;
|
||||
}
|
||||
|
||||
scrollDiffHeight() {
|
||||
let element = this.body.nativeElement;
|
||||
let diff = element.scrollHeight - this.scrollLastHeight;
|
||||
element.scrollTop += diff;
|
||||
}
|
||||
|
||||
sortItems() {
|
||||
this.items.sort((a, b) => {
|
||||
// sort in ascending order
|
||||
if(!a.tx.date) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if(!b.tx.date) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
|
||||
|
||||
if(dateDiff) {
|
||||
return dateDiff;
|
||||
}
|
||||
|
||||
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
|
||||
|
||||
return insertedDiff;
|
||||
});
|
||||
}
|
||||
|
||||
getTransferString(item: TxItem) {
|
||||
if(!item.tx.id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let transferAccountId = this.getTransferAccountId(item);
|
||||
|
||||
if(!transferAccountId) {
|
||||
return 'Split Transaction';
|
||||
}
|
||||
|
||||
let transferAccount = this.accountTree.accountMap[transferAccountId];
|
||||
|
||||
if(!transferAccount) {
|
||||
return 'Unidentified';
|
||||
}
|
||||
|
||||
return transferAccount.fullName;
|
||||
}
|
||||
|
||||
getTransferAccountId(item: TxItem): string {
|
||||
let transferAccountId = null;
|
||||
|
||||
if(item.tx.splits.length === 2) {
|
||||
transferAccountId = item.tx.splits[0].accountId === this.account.id ?
|
||||
item.tx.splits[1].accountId :
|
||||
item.tx.splits[0].accountId;
|
||||
}
|
||||
|
||||
return transferAccountId;
|
||||
}
|
||||
|
||||
getDebit(item: TxItem) {
|
||||
return item.activeSplit.amount >= 0 ? item.activeSplit.amount : null;
|
||||
}
|
||||
|
||||
getCredit(item: TxItem) {
|
||||
return item.activeSplit.amount < 0 ? -item.activeSplit.amount : null;
|
||||
}
|
||||
|
||||
createTxItems(transaction: Transaction) {
|
||||
let items: TxItem[] = [];
|
||||
|
||||
for(let i = 0; i < transaction.splits.length; i++) {
|
||||
let split = transaction.splits[i];
|
||||
|
||||
if(split.accountId !== this.accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = new TxItem();
|
||||
|
||||
item.tx = transaction;
|
||||
item.form = this.fb.group({
|
||||
splits: this.fb.array([])
|
||||
});
|
||||
item.activeSplit = split;
|
||||
item.activeSplitIndex = i;
|
||||
item.balance = 0;
|
||||
item.editing = false;
|
||||
item.edit$ = new Subject<any>();
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
appendTransaction(transaction: Transaction) {
|
||||
let items = this.createTxItems(transaction);
|
||||
this.items = this.items.concat(items);
|
||||
}
|
||||
|
||||
replaceTransaction(transaction: Transaction) {
|
||||
let items = this.createTxItems(transaction);
|
||||
|
||||
// remove tx from list
|
||||
for(let i = 0; i < this.items.length; i++) {
|
||||
if(this.items[i].tx.id === transaction.id) {
|
||||
this.items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// add new items
|
||||
this.items = this.items.concat(items);
|
||||
|
||||
this.sortItems();
|
||||
this.updateBalances();
|
||||
}
|
||||
|
||||
insertTransaction(transaction: Transaction) {
|
||||
this.appendTransaction(transaction);
|
||||
this.sortItems();
|
||||
this.updateBalances();
|
||||
}
|
||||
|
||||
updateBalances() {
|
||||
let balance = this.account.debitBalance ? this.account.balance : -this.account.balance;
|
||||
|
||||
for(let i = this.items.length - 1; i >= 0; i--) {
|
||||
let item = this.items[i];
|
||||
item.balance = balance;
|
||||
|
||||
if(item.activeSplit.amount) {
|
||||
if(this.account.debitBalance) {
|
||||
balance -= item.activeSplit.amount;
|
||||
} else {
|
||||
balance += item.activeSplit.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTransaction(transaction: Transaction) {
|
||||
this.insertTransaction(transaction);
|
||||
}
|
||||
|
||||
editTransaction(item: TxItem, $event) {
|
||||
if(item.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.debug($event);
|
||||
|
||||
this.log.debug('edit tx');
|
||||
this.log.debug(item);
|
||||
|
||||
item.editing = true;
|
||||
|
||||
let dateString = Util.getLocalDateString(item.tx.date);
|
||||
|
||||
this.log.debug(item);
|
||||
let debit = this.getDebit(item);
|
||||
let credit = this.getCredit(item);
|
||||
|
||||
let transferAccountId = this.getTransferAccountId(item);
|
||||
|
||||
if(item.tx.splits.length > 2) {
|
||||
transferAccountId = this.account.id;
|
||||
}
|
||||
|
||||
item.form = new FormGroup({
|
||||
date: new FormControl(dateString),
|
||||
description: new FormControl(item.tx.description, {updateOn: 'change'}),
|
||||
debit: new FormControl(debit ? debit / Math.pow(10, this.account.precision) : null),
|
||||
credit: new FormControl(credit ? credit / Math.pow(10, this.account.precision) : null),
|
||||
accountId: new FormControl(transferAccountId),
|
||||
splits: this.fb.array([])
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
let valueChanges = item.form.get('debit').valueChanges
|
||||
.merge(item.form.get('credit').valueChanges)
|
||||
.merge(item.form.get('splits').valueChanges);
|
||||
|
||||
valueChanges.subscribe(val => {
|
||||
if(!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.debug('value changes', val);
|
||||
this.solveEquations(item);
|
||||
this.fillEmptySplit(item);
|
||||
});
|
||||
|
||||
if(item.tx.splits.length > 2) {
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
for(let split of item.tx.splits) {
|
||||
if(split.accountId === this.accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let control = new FormGroup({
|
||||
accountId: new FormControl(split.accountId),
|
||||
debit: new FormControl(
|
||||
split.amount >= 0 ? split.amount / Math.pow(10, this.account.precision) : null
|
||||
),
|
||||
credit: new FormControl(
|
||||
split.amount < 0 ? -split.amount / Math.pow(10, this.account.precision) : null
|
||||
)
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
control.valueChanges.subscribe(val => {
|
||||
this.solveEquations(item);
|
||||
this.fillEmptySplit(item);
|
||||
});
|
||||
splits.push(control);
|
||||
|
||||
this.fillEmptySplit(item);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if($event && $event.target.className) {
|
||||
let cName = $event.target.classList[$event.target.classList.length - 1];
|
||||
try {
|
||||
this.renderer.selectRootElement('#form' + item.tx.id + item.activeSplitIndex + ' .' + cName + ' input').focus();
|
||||
} catch(e) {
|
||||
// don't do anything if the element doesn't exist
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// let modal = this.modalCtrl.create(EditTxPage, {transaction: transaction});
|
||||
// modal.present();
|
||||
// modal.onWillDismiss(() => {
|
||||
// this.loadData();
|
||||
// })
|
||||
item.edit$.next(null);
|
||||
}
|
||||
|
||||
preventBlur(item: TxItem) {
|
||||
this.log.debug('prevent blur');
|
||||
item.preventBlur = true;
|
||||
}
|
||||
|
||||
onBlur(item: TxItem) {
|
||||
this.log.debug('blur2');
|
||||
|
||||
setTimeout(() => {
|
||||
this.log.debug('blur', item.form.pristine);
|
||||
if(item.preventBlur) {
|
||||
item.preventBlur = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if(!item.form.pristine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let elem = document.activeElement as any;
|
||||
if(elem.form && elem.form.id === 'form' + item.tx.id + item.activeSplitIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.form = this.fb.group({
|
||||
splits: this.fb.array([])
|
||||
});
|
||||
|
||||
item.editing = false;
|
||||
}, 100); // timeout needs to be longer than in editTransaction
|
||||
}
|
||||
|
||||
deleteSplit(item: TxItem, index) {
|
||||
item.form.markAsDirty();
|
||||
this.log.debug('delete split');
|
||||
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
|
||||
if(splits.length === 1) {
|
||||
item.form.patchValue({
|
||||
accountId: splits.at(0).get('accountId').value
|
||||
});
|
||||
}
|
||||
|
||||
splits.removeAt(index);
|
||||
|
||||
}
|
||||
|
||||
addSplit(item: TxItem) {
|
||||
item.form.markAsDirty();
|
||||
//item.form.pristine = false;
|
||||
this.log.debug('add split');
|
||||
|
||||
// scroll down a little
|
||||
this.needsLittleScroll = true;
|
||||
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
|
||||
if(splits.length === 0) {
|
||||
this.addFirstSplit(item);
|
||||
return;
|
||||
}
|
||||
|
||||
let control = new FormGroup({
|
||||
accountId: new FormControl(),
|
||||
debit: new FormControl(),
|
||||
credit: new FormControl()
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
control.valueChanges.subscribe(val => {
|
||||
this.solveEquations(item);
|
||||
this.fillEmptySplit(item);
|
||||
});
|
||||
splits.push(control);
|
||||
|
||||
this.fillEmptySplit(item);
|
||||
}
|
||||
|
||||
addFirstSplit(item: TxItem) {
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
|
||||
let accountId = item.form.get('accountId').value || null;
|
||||
let debit = item.form.get('debit').value || null;
|
||||
let credit = item.form.get('credit').value || null;
|
||||
|
||||
item.form.patchValue({
|
||||
accountId: this.account.id
|
||||
});
|
||||
|
||||
let control = new FormGroup({
|
||||
accountId: new FormControl(accountId),
|
||||
debit: new FormControl(credit),
|
||||
credit: new FormControl(debit)
|
||||
}, {updateOn: 'blur'});
|
||||
|
||||
control.valueChanges.subscribe(val => {
|
||||
this.solveEquations(item);
|
||||
this.fillEmptySplit(item);
|
||||
});
|
||||
splits.push(control);
|
||||
|
||||
this.fillEmptySplit(item);
|
||||
}
|
||||
|
||||
fillEmptySplit(item: TxItem) {
|
||||
this.log.debug('fill empty split');
|
||||
|
||||
// Total up splits and fill in any empty split with the leftover value
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
|
||||
let emptySplit: AbstractControl;
|
||||
|
||||
let amount = item.form.get('debit').value - item.form.get('credit').value;
|
||||
|
||||
if(amount === 0) {
|
||||
emptySplit = item.form;
|
||||
this.log.debug('base split is empty');
|
||||
}
|
||||
|
||||
for(let i = 0; i < splits.length; i++) {
|
||||
let split = splits.at(i);
|
||||
amount += parseFloat(split.get('debit').value) || 0;
|
||||
amount -= parseFloat(split.get('credit').value) || 0;
|
||||
|
||||
if(!split.get('debit').value && !split.get('credit').value) {
|
||||
emptySplit = split;
|
||||
}
|
||||
}
|
||||
|
||||
if(emptySplit) {
|
||||
let precision = 2;
|
||||
|
||||
let account = this.accountTree.accountMap[emptySplit.get('accountId').value];
|
||||
if (account) {
|
||||
precision = account.precision;
|
||||
}
|
||||
|
||||
amount = this.round(-amount, precision);
|
||||
this.log.debug('amount', amount);
|
||||
|
||||
if(amount) {
|
||||
emptySplit.patchValue({
|
||||
debit: amount >= 0 ? amount : '',
|
||||
credit: amount < 0 ? -amount : ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
round(amount, precision) {
|
||||
return Math.round(amount * Math.pow(10, precision)) / Math.pow(10, precision);
|
||||
}
|
||||
|
||||
submit(item: TxItem) {
|
||||
this.error = null;
|
||||
|
||||
this.log.debug('submit!');
|
||||
this.log.debug(item.form.value);
|
||||
|
||||
if(item.form.pristine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let date = item.tx.id ? item.tx.date : new Date();
|
||||
let formDate = Util.getDateFromLocalDateString(item.form.value.date);
|
||||
|
||||
date = this.computeTransactionDate(formDate, date);
|
||||
|
||||
let tx = new Transaction({
|
||||
id: item.tx.id,
|
||||
date: date,
|
||||
description: item.form.value.description,
|
||||
splits: []
|
||||
});
|
||||
|
||||
if(!item.form.value.splits.length) {
|
||||
let amount = item.form.value.debit ? parseFloat(item.form.value.debit) : -parseFloat(item.form.value.credit);
|
||||
amount = Math.round(amount * Math.pow(10, this.account.precision));
|
||||
|
||||
tx.splits.push(new Split({
|
||||
accountId: this.account.id,
|
||||
amount: amount,
|
||||
nativeAmount: amount
|
||||
}));
|
||||
|
||||
tx.splits.push(new Split({
|
||||
accountId: item.form.value.accountId,
|
||||
amount: -amount,
|
||||
nativeAmount: -amount
|
||||
}));
|
||||
} else {
|
||||
let amount = item.form.value.debit ? parseFloat(item.form.value.debit) : -parseFloat(item.form.value.credit);
|
||||
amount = Math.round(amount * Math.pow(10, this.account.precision));
|
||||
|
||||
tx.splits.push(new Split({
|
||||
accountId: item.form.value.accountId,
|
||||
amount: amount,
|
||||
nativeAmount: amount
|
||||
}));
|
||||
}
|
||||
|
||||
for(let i = 0; i < item.form.value.splits.length; i++) {
|
||||
let split = item.form.value.splits[i];
|
||||
let account = this.accountTree.accountMap[split.accountId];
|
||||
|
||||
if(!account) {
|
||||
this.error = new AppError('Invalid account');
|
||||
return;
|
||||
}
|
||||
|
||||
let amount = split.debit ? parseFloat(split.debit) : -parseFloat(split.credit);
|
||||
amount = Math.round(amount * Math.pow(10, account.precision));
|
||||
|
||||
tx.splits.push(new Split({
|
||||
accountId: split.accountId,
|
||||
amount: amount,
|
||||
nativeAmount: amount
|
||||
}));
|
||||
}
|
||||
|
||||
this.log.debug(tx);
|
||||
|
||||
if(tx.id) {
|
||||
// update tx
|
||||
let oldId = tx.id;
|
||||
tx.id = Util.newGuid();
|
||||
|
||||
this.txService.putTransaction(oldId, tx)
|
||||
.subscribe(tx => {
|
||||
// do nothing
|
||||
|
||||
}, error => {
|
||||
this.error = error;
|
||||
});
|
||||
} else {
|
||||
// new tx
|
||||
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
while(splits.length) {
|
||||
splits.removeAt(0);
|
||||
}
|
||||
|
||||
item.form.reset();
|
||||
|
||||
let newTx = new Transaction({
|
||||
date: new Date(),
|
||||
splits: []
|
||||
});
|
||||
|
||||
newTx.date.setHours(23, 59, 59, 999);
|
||||
|
||||
newTx.splits.push(new Split({
|
||||
accountId: this.account.id
|
||||
}));
|
||||
newTx.splits.push(new Split());
|
||||
item.tx = newTx;
|
||||
|
||||
item.editing = false;
|
||||
item.activeSplit = newTx.splits[0];
|
||||
item.activeSplitIndex = 0;
|
||||
|
||||
tx.id = Util.newGuid();
|
||||
|
||||
this.txService.newTransaction(tx)
|
||||
.subscribe(tx => {
|
||||
// do nothing
|
||||
|
||||
}, error => {
|
||||
this.error = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
computeTransactionDate(formDate: Date, txDate: Date): Date {
|
||||
if(formDate.getTime()) {
|
||||
// make the time be at the very end of the day
|
||||
formDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
let sameDay = formDate.getFullYear() === txDate.getFullYear() &&
|
||||
formDate.getMonth() === txDate.getMonth() &&
|
||||
formDate.getDate() === txDate.getDate();
|
||||
|
||||
if(formDate.getTime() && !sameDay) {
|
||||
txDate = formDate;
|
||||
}
|
||||
|
||||
return txDate;
|
||||
}
|
||||
|
||||
deleteTransaction(item) {
|
||||
this.modalService.open(this.confirmDeleteModal).result.then((result) => {
|
||||
this.log.debug('delete');
|
||||
this.txService.deleteTransaction(item.tx.id)
|
||||
.subscribe(() => {
|
||||
this.log.debug('successfully deleted transaction ' + item.tx.id);
|
||||
}, error => {
|
||||
this.error = error;
|
||||
})
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel delete');
|
||||
});
|
||||
}
|
||||
|
||||
advancedEdit(item) {
|
||||
let modal = this.modalService.open(AdvancedEdit, {size: 'lg'});
|
||||
|
||||
modal.componentInstance.setData(item, this.accountTree);
|
||||
|
||||
modal.result.then((result) => {
|
||||
this.log.debug('advanced edit save');
|
||||
this.log.debug(item.form);
|
||||
}, (reason) => {
|
||||
this.log.debug('cancel advanced edit');
|
||||
});
|
||||
}
|
||||
|
||||
onEnter(item, $event) {
|
||||
$event.target.blur();
|
||||
this.submit(item);
|
||||
}
|
||||
|
||||
solveEquations(item: TxItem) {
|
||||
this.log.debug('solveEquations');
|
||||
let originalDebit = item.form.get('debit').value;
|
||||
let originalCredit = item.form.get('credit').value;
|
||||
let precision = this.account.precision;
|
||||
let debit = originalDebit ? this.round(this.solve('' + originalDebit), precision) : '';
|
||||
let credit = originalCredit ? this.round(this.solve('' + originalCredit), precision) : '';
|
||||
|
||||
if((originalDebit && debit !== originalDebit) || (originalCredit && credit !== originalCredit)) {
|
||||
this.log.debug('patch', debit, credit);
|
||||
this.log.debug('original', originalDebit, originalCredit);
|
||||
item.form.patchValue({
|
||||
debit: debit,
|
||||
credit: credit
|
||||
});
|
||||
}
|
||||
|
||||
let splits = item.form.get('splits') as FormArray;
|
||||
|
||||
for(let i = 0; i < splits.length; i++) {
|
||||
let split = splits.at(i);
|
||||
let originalDebit = split.get('debit').value;
|
||||
let originalCredit = split.get('credit').value;
|
||||
let debit = originalDebit ? this.round(this.solve('' + originalDebit), precision) : '';
|
||||
let credit = originalCredit ? this.round(this.solve('' + originalCredit), precision) : '';
|
||||
|
||||
if((originalDebit && debit !== originalDebit) || (originalCredit && credit !== originalCredit)) {
|
||||
this.log.debug('patch', debit, credit);
|
||||
this.log.debug('original', originalDebit, originalCredit);
|
||||
split.patchValue({
|
||||
debit: debit,
|
||||
credit: credit
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
solve(input: string) {
|
||||
// first pass: +-
|
||||
for(let i = input.length - 1; i >= 0; i--) {
|
||||
if(input.charAt(i) === '+') {
|
||||
return this.solve(input.slice(0, i)) + this.solve(input.slice(i + 1));
|
||||
} else if(input.charAt(i) === '-') {
|
||||
return this.solve(input.slice(0, i)) - this.solve(input.slice(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// second pass: */
|
||||
for(let i = input.length - 1; i >= 0; i--) {
|
||||
if(input.charAt(i) === '*') {
|
||||
return this.solve(input.slice(0, i)) * this.solve(input.slice(i + 1));
|
||||
} else if(input.charAt(i) === '/') {
|
||||
return this.solve(input.slice(0, i)) / this.solve(input.slice(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return parseFloat(input.trim()) || 0;
|
||||
}
|
||||
|
||||
autocomplete(item: TxItem, tx: Transaction) {
|
||||
this.log.debug('chose tx', tx);
|
||||
|
||||
let formDate = Util.getDateFromLocalDateString(item.form.value.date);
|
||||
item.tx = new Transaction(
|
||||
{
|
||||
date: this.computeTransactionDate(formDate, new Date()),
|
||||
description: tx.description,
|
||||
splits: tx.splits
|
||||
}
|
||||
);
|
||||
|
||||
for(let i = 0; i < tx.splits.length; i++) {
|
||||
let split = tx.splits[i];
|
||||
|
||||
if(split.accountId !== this.accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.activeSplit = split;
|
||||
item.activeSplitIndex = i;
|
||||
}
|
||||
|
||||
this.log.debug(tx);
|
||||
|
||||
item.editing = false;
|
||||
item.preventBlur = true;
|
||||
this.editTransaction(item, {target: {className: 'description', classList: ['description']}});
|
||||
item.form.markAsDirty();
|
||||
}
|
||||
|
||||
}
|
||||
31
src/app/transaction/transaction.module.ts
Normal file
31
src/app/transaction/transaction.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { TxListPage } from './list';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { AppRoutingModule } from '../app-routing.module';
|
||||
import { AdvancedEdit } from './advancededit';
|
||||
import { Autocomplete } from './autocomplete';
|
||||
import { Breadcrumbs } from './breadcrumbs';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
TxListPage,
|
||||
AdvancedEdit,
|
||||
Autocomplete,
|
||||
Breadcrumbs
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
NgbModule,
|
||||
ReactiveFormsModule,
|
||||
SharedModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
exports: [],
|
||||
providers: [],
|
||||
entryComponents: [AdvancedEdit]
|
||||
})
|
||||
export class TransactionModule { }
|
||||
14
src/app/transaction/txitem.ts
Normal file
14
src/app/transaction/txitem.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Transaction, Split} from '../shared/transaction';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export class TxItem {
|
||||
tx: Transaction;
|
||||
activeSplit: Split;
|
||||
activeSplitIndex: number;
|
||||
form: FormGroup;
|
||||
balance: number;
|
||||
editing: boolean;
|
||||
preventBlur: boolean;
|
||||
edit$: Subject<any>;
|
||||
}
|
||||
29
src/app/user/login.html
Normal file
29
src/app/user/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<h1>Login</h1>
|
||||
|
||||
<div class="section">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input formControlName="email" type="email" class="form-control" id="email" placeholder="Enter email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input formControlName="password" type="password" class="form-control" id="password" placeholder="Password">
|
||||
</div>
|
||||
<div class="form-group form-check">
|
||||
<input formControlName="stayLoggedIn" id="stayLoggedIn" type="checkbox" class="form-check-input" />
|
||||
<label for="stayedLoggedIn" class="form-check-label">Stay logged in</label>
|
||||
</div>
|
||||
<p *ngIf="error" class="error">
|
||||
{{error.message}}
|
||||
</p>
|
||||
<p *ngIf="resetSuccess">
|
||||
Password reset link sent.
|
||||
</p>
|
||||
<p>
|
||||
<a routerLink="/register">Register for a new account</a><br>
|
||||
<a (click)="resetPassword()">Reset password</a>
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
0
src/app/user/login.scss
Normal file
0
src/app/user/login.scss
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user