initial commit

This commit is contained in:
Patrick Nagurny
2018-10-19 11:28:08 -04:00
commit 5ff09d328d
139 changed files with 23448 additions and 0 deletions

View 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">&times;</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>

View File

@@ -0,0 +1,9 @@
/*.account {
overflow: hidden;
direction: rtl;
text-overflow: ellipsis;
white-space: nowrap;
}*/
.splits .row {
margin-bottom: 10px;
}

View 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;
}
}

View 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>

View 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;
}

View 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;
}
}

View 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"> &gt; </span>
</span>
</div>

View File

View 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;
}
}
}

View 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">&times;</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>

View 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
View 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();
}
}

View 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 { }

View 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>;
}