WIP: Integrate with API

This commit is contained in:
William Brawner 2018-09-16 19:52:07 -05:00
parent 09e32d1cd1
commit da7a7c94f6
26 changed files with 276 additions and 122 deletions

5
package-lock.json generated
View file

@ -5829,6 +5829,11 @@
"integrity": "sha512-vdqTKI9GBIYcAEbFAcpKPErKINfPF5zIuz3/niBfq8WUZjpT2tytLlFVrBgWdOtqI4uaA/Rb6No0hux39XXDuw==",
"dev": true
},
"ng2-currency-mask": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ng2-currency-mask/-/ng2-currency-mask-5.3.1.tgz",
"integrity": "sha1-1z4nv2DqQj38AEAQbI6hcxbQeqs="
},
"no-case": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz",

View file

@ -27,6 +27,7 @@
"core-js": "^2.5.4",
"dexie": "^2.0.4",
"hammerjs": "^2.0.8",
"ng2-currency-mask": "^5.3.1",
"rxjs": "^6.0.0",
"zone.js": "~0.8.26"
},

View file

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { AccountService } from './account.service';
describe('AccountService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AccountService]
});
});
it('should be created', inject([AccountService], (service: AccountService) => {
expect(service).toBeTruthy();
}));
});

View file

@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class AccountsService {
export class AccountService {
constructor() { }
}

View file

@ -1,15 +0,0 @@
import { TestBed, inject } from '@angular/core/testing';
import { AccountsService } from './accounts.service';
describe('AccountsService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AccountsService]
});
});
it('should be created', inject([AccountsService], (service: AccountsService) => {
expect(service).toBeTruthy();
}));
});

View file

@ -6,7 +6,7 @@
<input matInput [(ngModel)]="currentCategory.name" placeholder="Name" required>
</mat-form-field>
<mat-form-field>
<input matInput type="number" [(ngModel)]="currentCategory.amount" placeholder="Amount" required>
<input matInput type="text" [(ngModel)]="currentCategory.amount" placeholder="Amount" required currencyMask>
</mat-form-field>
<!--
<mat-form-field>

View file

@ -1,7 +1,6 @@
import { Component, OnInit, Input, OnDestroy } from '@angular/core';
import { CategoryService } from '../category.service'
import { Category } from '../category'
import { Location } from '@angular/common';
import { CategoryService } from '../category.service';
import { Category } from '../category';
import { Actionable } from '../actionable';
import { AppComponent } from '../app.component';
@ -31,6 +30,7 @@ export class AddEditCategoryComponent implements OnInit, Actionable, OnDestroy {
}
doAction(): void {
this.currentCategory.amount *= 100;
if (this.currentCategory.id) {
// This is an existing category, update it
this.categoryService.updateCategory(this.currentCategory);

View file

@ -9,7 +9,7 @@
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description"></textarea>
</mat-form-field>
<mat-form-field>
<input matInput type="number" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required>
<input matInput type="text" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required currencyMask>
</mat-form-field>
<mat-form-field>
<input matInput type="date" [(ngModel)]="currentTransaction.date" placeholder="Date" required>

View file

@ -18,6 +18,7 @@ export class AddEditTransactionComponent implements OnInit, OnDestroy, Actionabl
public transactionType = TransactionType;
public selectedCategory: Category;
public categories: Category[];
public rawAmount: string;
constructor(
private app: AppComponent,
@ -37,6 +38,9 @@ export class AddEditTransactionComponent implements OnInit, OnDestroy, Actionabl
}
doAction(): void {
// The amount will be input as a decimal value so we need to convert it
// to an integer
this.currentTransaction.amount *= 100;
if (this.currentTransaction.id) {
// This is an existing transaction, update it
this.transactionService.updateTransaction(this.currentTransaction);

View file

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { Transaction } from './transaction';
const httpOptions = {
headers: new HttpHeaders({
@ -49,4 +50,42 @@ export class ApiService {
httpOptions
);
}
getTransactions(): Observable<any> {
return this.http.get(
host + '/transactions',
httpOptions
);
}
saveTransaction(transaction: Transaction): Observable<Transaction> {
return Observable.create(subscriber => {
const params = {
name: transaction.title,
amount: transaction.amount,
accountId: transaction.accountId,
categoryId: transaction.categoryId,
description: transaction.description,
date: transaction.date,
type: transaction.type,
};
if (transaction.remoteId > 0) {
params['id'] = transaction.remoteId;
}
this.http.post(
host + '/transactions',
params,
httpOptions,
).subscribe(
value => {
console.log(value);
},
error => {
console.error(error);
}
);
});
}
}

View file

@ -2,6 +2,7 @@
<mat-sidenav #sidenav mode="over" closed>
<mat-nav-list (click)="sidenav.close()">
<a mat-list-item routerLink="/accounts">Manage Accounts</a>
<a mat-list-item (click)="exportData()">Export Data</a>
<a mat-list-item *ngIf="!authService.currentUser" routerLink="/login">Login</a>
<a mat-list-item *ngIf="!authService.currentUser" routerLink="/register">Register</a>
<a mat-list-item *ngIf="authService.currentUser" (click)="logout()">Logout</a>

View file

@ -2,6 +2,7 @@ import { Component } from '@angular/core';
import { Location } from '@angular/common';
import { Actionable } from './actionable';
import { AuthService } from './auth.service';
import { BudgetDatabase } from './budget-database';
@Component({
selector: 'app-root',
@ -15,7 +16,8 @@ export class AppComponent {
constructor(
public authService: AuthService,
private location: Location
private location: Location,
private db: BudgetDatabase,
) { }
goBack(): void {
@ -25,4 +27,20 @@ export class AppComponent {
logout(): void {
this.authService.logout();
}
exportData(): void {
this.db.export().subscribe(data => {
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.download = 'budget.json';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
});
}
}

View file

@ -36,6 +36,18 @@ import { AddEditAccountComponent } from './add-edit-account/add-edit-account.com
import { EditProfileComponent } from './edit-profile/edit-profile.component';
import { UserComponent } from './user/user.component';
import { HttpClientModule } from '@angular/common/http';
import { CurrencyMaskModule } from "ng2-currency-mask";
import { CurrencyMaskConfig, CURRENCY_MASK_CONFIG } from "ng2-currency-mask/src/currency-mask.config";
export const CustomCurrencyMaskConfig: CurrencyMaskConfig = {
align: 'left',
precision: 2,
prefix: '',
thousands: ',',
decimal: '.',
suffix: '',
allowNegative: false,
};
@NgModule({
declarations: [
@ -74,8 +86,11 @@ import { HttpClientModule } from '@angular/common/http';
FormsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
HttpClientModule,
CurrencyMaskModule,
],
providers: [
{ provide: CURRENCY_MASK_CONFIG, useValue: CustomCurrencyMaskConfig },
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View file

@ -7,14 +7,12 @@ import { Router } from '@angular/router';
providedIn: 'root'
})
export class AuthService {
private currentUser: User;
public currentUser: User;
constructor(
private apiService: ApiService,
private router: Router,
) {
console.log('AuthService constructed');
}
) { }
login(user: User) {
this.apiService.login(user.name, user.password).subscribe(

View file

@ -4,64 +4,92 @@ import { Account } from './account'
import { Category } from './category'
import { Transaction } from './transaction'
import { TransactionType } from './transaction.type';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
providedIn: 'root'
})
export class BudgetDatabase extends Dexie {
transactions: Dexie.Table<ITransaction, number>;
categories: Dexie.Table<ICategory, number>;
accounts: Dexie.Table<IAccount, number>;
transactions: Dexie.Table<ITransaction, number>;
categories: Dexie.Table<ICategory, number>;
accounts: Dexie.Table<IAccount, number>;
constructor() {
super('BudgetDatabase')
this.version(1).stores({
transactions: `++id, title, description, amount, date, category, type`,
categories: `++id, name, amount, repeat, color`
})
this.version(2).stores({
transactions: `++id, title, description, amount, date, category_id, type`,
categories: `++id, name, amount, repeat, color, type`
})
this.version(3).stores({
transactions: `++id, title, description, amount, date, category_id, type`,
categories: `++id, name, amount, repeat, color`
})
this.version(4).stores({
transactions: `++id, remote_id, account_id, title, description, amount, date, category_id, type`,
categories: `++id, remote_id, account_id, name, amount, repeat, color`,
accounts: `++id, remote_id, name`
})
this.transactions.mapToClass(Transaction)
this.categories.mapToClass(Category)
this.accounts.mapToClass(Account)
}
constructor() {
super('BudgetDatabase')
this.version(1).stores({
transactions: `++id, title, description, amount, date, category, type`,
categories: `++id, name, amount, repeat, color`
});
this.version(2).stores({
transactions: `++id, title, description, amount, date, category_id, type`,
categories: `++id, name, amount, repeat, color, type`
});
this.version(3).stores({
transactions: `++id, title, description, amount, date, category_id, type`,
categories: `++id, name, amount, repeat, color`
});
this.version(4).stores({
transactions: `++id, remote_id, account_id, title, description, amount, date, category_id, type`,
categories: `++id, remote_id, account_id, name, amount, repeat, color`,
accounts: `++id, remote_id, name`
}).upgrade(dbTransaction => {
// Since the server stores amounts as integers, we need to modify the locally stored
// values to also use integers
return dbTransaction.tables.transactions.toCollection().modify(transaction => {
transaction.amount *= 100;
}).then(count => {
dbTransaction.tables.categories.toCollection().modify(category => {
category.amount *= 100;
});
});
});
this.version(5).stores({
transactions: `++id, &remote_id, account_id, title, description, amount, date, category_id, type`,
categories: `++id, &remote_id, account_id, name, amount, repeat, color`,
accounts: `++id, &remote_id, name`
});
this.transactions.mapToClass(Transaction);
this.categories.mapToClass(Category);
this.accounts.mapToClass(Account);
}
export(): Observable<Array<any>> {
const db = this;
return Observable.create(observer => {
const dump = {};
db.tables.forEach(table => {
dump[table.name] = table.toArray();
});
observer.next(dump);
observer.complete();
});
}
}
export interface ITransaction {
id: number;
accountId: number;
remoteId: number;
title: string;
description: string;
amount: number;
date: Date;
categoryId: number;
type: TransactionType;
id: number;
accountId: number;
remoteId: number;
title: string;
description: string;
amount: number;
date: Date;
categoryId: number;
type: TransactionType;
}
export interface ICategory {
id: number;
accountId: number;
remoteId: number;
name: string;
amount: number;
repeat: string;
color: string;
id: number;
accountId: number;
remoteId: number;
name: string;
amount: number;
repeat: string;
color: string;
}
export interface IAccount {
id: number;
remoteId: number;
name: string;
id: number;
remoteId: number;
name: string;
}

View file

@ -20,6 +20,7 @@ export class CategoriesComponent implements OnInit {
ngOnInit() {
this.app.title = 'Categories';
this.app.backEnabled = true;
this.getCategories();
this.categoryBalances = new Map();
}

View file

@ -24,6 +24,9 @@ export class CategoryDetailsComponent implements OnInit {
getCategory(): void {
const id = +this.route.snapshot.paramMap.get('id')
this.categoryService.getCategory(id)
.subscribe(category => this.category = category)
.subscribe(category => {
category.amount /= 100;
this.category = category;
});
}
}

View file

@ -16,30 +16,13 @@ export class CategoryListComponent implements OnInit {
ngOnInit() {
}
/*
ngAfterViewInit() {
this.categoryProgressBars.changes.subscribe( list =>
list.forEach(progressBar =>
progressBar._elementRef.nativeElement.innerHTML += `
<style>
.mat-progress-bar-fill::after {
background-color: ${this.categories[0].color};
color: purple;
}
</style>
`
)
)
}
*/
getCategoryRemainingBalance(category: Category): number {
let categoryBalance = this.categoryBalances.get(category.id)
let categoryBalance = this.categoryBalances.get(category.id);
if (!categoryBalance) {
categoryBalance = 0
categoryBalance = 0;
}
return category.amount + categoryBalance;
return (category.amount / 100) + (categoryBalance / 100);
}
getCategoryCompletion(category: Category): number {
@ -49,12 +32,12 @@ export class CategoryListComponent implements OnInit {
let categoryBalance = this.categoryBalances.get(category.id)
if (!categoryBalance) {
categoryBalance = 0
categoryBalance = 0;
}
// Invert the negative/positive values for calculating progress
// since the limit for a category is saved as a positive but the
// balance is used in the calculation.
// balance is used in the calculation.
if (categoryBalance < 0) {
categoryBalance = Math.abs(categoryBalance)
} else {

View file

@ -2,7 +2,7 @@
<div class="dashboard-primary">
<h2 class="balance">
Current Balance: <br />
<span [ngClass]="{'income': balance > 0, 'expense': balance < 0}" >{{ balance | currency }}</span>
<span [ngClass]="{'income': balance > 0, 'expense': balance < 0}" >{{ balance / 100 | currency }}</span>
</h2>
<div class="transaction-navigation">
<a mat-button routerLink="/transactions">View Transactions</a>

View file

@ -9,6 +9,6 @@
<input matInput type="password" placeholder="Password" [(ngModel)]="user.password" />
</mat-form-field>
<mat-form-field>
<input matInput type="password" placeholder="Confirm Password" />
<input matInput type="password" placeholder="Confirm Password" [(ngModel)]="confirmedPassword" />
</mat-form-field>
</div>

View file

@ -12,6 +12,7 @@ import { Actionable } from '../actionable';
export class RegisterComponent implements OnInit, OnDestroy, Actionable {
public user: User = new User();
public confirmedPassword: string;
constructor(
private app: AppComponent,
@ -29,6 +30,10 @@ export class RegisterComponent implements OnInit, OnDestroy, Actionable {
}
doAction(): void {
if (this.user.password !== this.confirmedPassword) {
alert('Passwords don\'t match');
return;
}
this.authService.register(this.user);
}

View file

@ -1,7 +1,7 @@
import { Component, OnInit, Input } from '@angular/core';
import { TransactionService } from '../transaction.service'
import { ActivatedRoute } from '@angular/router';
import { Transaction } from '../transaction'
import { Transaction } from '../transaction'
@Component({
selector: 'app-transaction-details',
@ -24,6 +24,9 @@ export class TransactionDetailsComponent implements OnInit {
getTransaction(): void {
const id = +this.route.snapshot.paramMap.get('id')
this.transactionService.getTransaction(id)
.subscribe(transaction => this.transaction = transaction)
.subscribe(transaction => {
transaction.amount /= 100;
this.transaction = transaction;
});
}
}

View file

@ -2,54 +2,85 @@ import { Injectable } from '@angular/core';
import { of, Observable, from } from 'rxjs';
import { Transaction } from './transaction';
import { TransactionType } from './transaction.type';
import { BudgetDatabase } from './budget-database';
import { BudgetDatabase, ITransaction } from './budget-database';
import { AuthService } from './auth.service';
import { ApiService } from './api.service';
@Injectable({
providedIn: 'root'
})
export class TransactionService {
constructor(private db: BudgetDatabase) { }
constructor(
private db: BudgetDatabase,
private authService: AuthService,
private apiService: ApiService,
) { }
getTransactions(count?: number): Observable<Transaction[]> {
getTransactions(count?: number): Observable<ITransaction[]> {
// Check if we have a currently logged in user
if (this.authService.currentUser) {
this.apiService.getTransactions().subscribe(
value => {
console.log(value);
},
error => {
console.error(error);
}
);
}
if (count) {
return from(this.db.transactions.orderBy('date').reverse().limit(count).toArray())
return from(this.db.transactions.orderBy('date').reverse().limit(count).toArray());
} else {
return from(this.db.transactions.orderBy('date').reverse().toArray())
return from(this.db.transactions.orderBy('date').reverse().toArray());
}
}
getTransaction(id: number): Observable<Transaction> {
return from(this.db.transactions.where('id').equals(id).first())
return Observable.create(subscriber => {
this.db.transactions.where('id').equals(id).first().then(transaction => {
if (!transaction) {
subscriber.error();
subscriber.complete();
return;
}
(transaction as Transaction).loadCategory(this.db);
(transaction as Transaction).loadAccount(this.db);
subscriber.next(transaction);
});
});
}
saveTransaction(transaction: Transaction): Observable<Transaction> {
this.db.transactions.put(transaction)
return of(transaction)
this.db.transactions.put(transaction);
if (this.authService.currentUser) {
return this.apiService.saveTransaction(transaction);
} else {
return of(transaction);
}
}
updateTransaction(transaction: Transaction): Observable<any> {
this.db.transactions.update(transaction.id, transaction)
return of([])
this.db.transactions.update(transaction.id, transaction);
return of([]);
}
deleteTransaction(transaction: Transaction): Observable<any> {
return from(this.db.transactions.delete(transaction.id))
return from(this.db.transactions.delete(transaction.id));
}
getBalance(): Observable<number> {
let sum = 0;
return from(
this.db.transactions.each(function(transaction) {
this.db.transactions.each(function (transaction) {
if (transaction.type === TransactionType.INCOME) {
sum += transaction.amount
sum += transaction.amount;
} else {
sum -= transaction.amount
sum -= transaction.amount;
}
}).then(function() {
}).then(function () {
return sum;
})
)
);
}
}

View file

@ -1,5 +1,5 @@
import { ITransaction } from './budget-database'
import { Category } from './category'
import { ITransaction, ICategory, BudgetDatabase, IAccount } from './budget-database';
import { Category } from './category';
import { TransactionType } from './transaction.type';
export class Transaction implements ITransaction {
@ -12,4 +12,18 @@ export class Transaction implements ITransaction {
date: Date = new Date();
categoryId: number;
type: TransactionType = TransactionType.EXPENSE;
category: ICategory;
account: IAccount;
loadCategory(db: BudgetDatabase) {
db.categories.where('id').equals(this.categoryId).first().then(category => {
this.category = category;
});
}
loadAccount(db: BudgetDatabase) {
db.accounts.where('id').equals(this.accountId).first().then(account => {
this.account = account;
});
}
}

View file

@ -2,12 +2,13 @@
<a mat-list-item *ngFor="let transaction of transactions" routerLink="/transactions/{{ transaction.id }}">
<div matLine class="list-row-one">
<p>{{transaction.title}}</p>
<p class="amount" [class.expense]="transaction.type === transactionType.EXPENSE" [class.income]="transaction.type === transactionType.INCOME">{{
transaction.amount | currency }}</p>
<p class="amount" [class.expense]="transaction.type === transactionType.EXPENSE" [class.income]="transaction.type === transactionType.INCOME">
{{ transaction.amount / 100 | currency }}
</p>
</div>
<p matLine class="text-small">{{ transaction.date | date }}</p>
</a>
</mat-nav-list>
<a mat-fab routerLink="/transactions/new">
<mat-icon aria-label="Add">add</mat-icon>
</a>
</a>

View file

@ -90,6 +90,10 @@ mat-sidenav-container .mat-drawer-backdrop.mat-drawer-shown {
background-color: rgba(0, 0, 0, 0.8);
}
mat-sidenav {
min-width: 300px;
}
.income {
color: #81C784;
}