Compare commits

..

2 commits

Author SHA1 Message Date
8a09ae2a15
npm audit fix 2024-07-05 23:14:25 +00:00
e3e8d4805c
Nuke Angular codebase 2024-03-18 19:49:46 -06:00
137 changed files with 27 additions and 33667 deletions

View file

@ -1,12 +0,0 @@
FROM mcr.microsoft.com/devcontainers/javascript-node:0-16-bullseye
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node modules
# RUN su node -c "npm install -g <your-package-list-here>"

View file

@ -1,23 +0,0 @@
// Update the VARIANT arg in docker-compose.yml to pick a Node.js version
{
"name": "Twigs Web",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// Features to add to the dev container. More info: https://containers.dev/implementors/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [4200, "backend:8080"]
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -1,46 +0,0 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspaces:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Uncomment the next line to use a non-root user for all processes.
user: node
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
backend:
image: ghcr.io/wbrawner/twigs-server:main
restart: unless-stopped
environment:
TWIGS_DB_HOST: db
TWIGS_DB_NAME: postgres
TWIGS_DB_USER: postgres
TWIGS_DB_PASS: postgres
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
volumes:
postgres-data:

View file

@ -1,5 +0,0 @@
{
"projects": {
"default": "budget-c7da5"
}
}

View file

@ -1,20 +0,0 @@
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on merge
'on':
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm ci && npm run package
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_BUDGET_C7DA5 }}'
channelId: live
projectId: budget-c7da5

24
.vscode/launch.json vendored
View file

@ -1,24 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Chrome",
"request": "launch",
"type": "pwa-chrome",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
},
{
"name": "ng serve",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
"runtimeArgs": [
"serve"
]
}
]
}

View file

@ -1,7 +1,5 @@
# Twigs Web Client
# IMPORTANT: This repository is no longer maintained. The web version of Twigs has been replaced with a server-rendered implementation in the [backend repository](https://github.com/wbrawner/twigs)
Twigs is an open source budgeting app aimed at people who need to share a budget. This project serves as the web front end, and is powered by Angular. The main back end project can be found at [wbrawner/twigs-server](https://github.com/wbrawner/twigs-server)
## Building
@ -46,4 +44,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
```

View file

@ -1,144 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"twigs": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/twigs",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/browserconfig.xml",
"src/favicon-16x16.png",
"src/favicon-32x32.png",
"src/favicon-96x96.png",
"src/favicon.ico",
"src/assets",
"src/manifest.json"
],
"styles": [
"src/styles.css",
"src/styles.scss"
],
"scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"serviceWorker": true
},
"codeserver": {
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.codeserver.ts"
}
]
}
},
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "twigs:build"
},
"configurations": {
"production": {
"buildTarget": "twigs:build:production"
},
"codeserver": {
"buildTarget": "twigs:build:codeserver"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "twigs:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.css"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.json"
]
}
}
}
},
"twigs-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "twigs:serve"
},
"configurations": {
"production": {
"devServerTarget": "twigs:serve:production"
}
}
}
}
}
},
"cli": {
"analytics": "b8304464-255e-47bb-976a-7ed81af63238"
}
}

View file

@ -1,4 +0,0 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}

View file

@ -1,28 +0,0 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View file

@ -1,14 +0,0 @@
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to budget!');
});
});

View file

@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}

View file

@ -1,13 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

View file

@ -1,16 +0,0 @@
{
"hosting": {
"public": "dist/twigs",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

View file

@ -1,26 +0,0 @@
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**"
]
}
}
]
}

29127
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,14 @@
{
"name": "budget",
"version": "0.0.0",
"name": "twigs",
"version": "0.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve --configuration=production --host '0.0.0.0'",
"code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000",
"build": "ng build",
"package": "ng build --configuration=production --service-worker",
"publish": "ng build --configuration=production --service-worker && firebase deploy",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"update": "ncu -u"
"start": ""
},
"private": true,
"dependencies": {
"@angular/animations": "^17.2.3",
"@angular/cdk": "^17.2.1",
"@angular/common": "^17.2.3",
"@angular/compiler": "^17.2.3",
"@angular/core": "^17.2.3",
"@angular/forms": "^17.2.3",
"@angular/material": "^16.2.0",
"@angular/platform-browser": "^17.2.3",
"@angular/platform-browser-dynamic": "^17.2.3",
"@angular/router": "^17.2.3",
"@angular/service-worker": "^17.2.3",
"chart.js": "^3.7.0",
"core-js": "^3.20.3",
"ng2-charts": "^3.0.8",
"rxjs": "^7.5.2",
"tslib": "^2.3.1",
"zone.js": "~0.14.4"
"chart.js": "^3.7.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.2.2",
"@angular/cli": "^17.2.2",
"@angular/compiler-cli": "^17.2.3",
"@angular/language-service": "^17.2.3",
"@types/jasmine": "~3.10.3",
"@types/jasminewd2": "^2.0.10",
"@types/node": "^17.0.10",
"eslint": "^8.7.0",
"jasmine-core": "~4.0.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.3.11",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.1",
"karma-jasmine-html-reporter": "^1.7.0",
"npm-check-updates": "^15.0.1",
"protractor": "^7.0.0",
"ts-node": "~10.4.0",
"tslint": "^6.1.3",
"typescript": "5.3.3"
"eslint": "^8.7.0"
}
}
}

View file

@ -1,11 +0,0 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
#
# For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11

View file

@ -1,13 +0,0 @@
import { AppRoutingModule } from './app-routing.module';
describe('AppRoutingModule', () => {
let appRoutingModule: AppRoutingModule;
beforeEach(() => {
appRoutingModule = new AppRoutingModule();
});
it('should create an instance', () => {
expect(appRoutingModule).toBeTruthy();
});
});

View file

@ -1,43 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TransactionsComponent } from './transactions/transactions.component';
import { TransactionDetailsComponent } from './transactions/transaction-details/transaction-details.component';
import { NewTransactionComponent } from './transactions/new-transaction/new-transaction.component';
import { CategoriesComponent } from './categories/categories.component';
import { CategoryDetailsComponent } from './categories/category-details/category-details.component';
import { NewCategoryComponent } from './categories/new-category/new-category.component';
import { LoginComponent } from './users/login/login.component';
import { RegisterComponent } from './users/register/register.component';
import { BudgetsComponent } from './budgets/budget.component';
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
const routes: Routes = [
{ path: '', component: BudgetsComponent },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'budgets', component: BudgetsComponent },
{ path: 'budgets/new', component: NewBudgetComponent },
{ path: 'budgets/:id', component: BudgetDetailsComponent },
{ path: 'budgets/:id/edit', component: EditBudgetComponent },
{ path: 'transactions', component: TransactionsComponent },
{ path: 'transactions/new', component: NewTransactionComponent },
{ path: 'transactions/:id', component: TransactionDetailsComponent },
{ path: 'categories', component: CategoriesComponent },
{ path: 'categories/new', component: NewCategoryComponent },
{ path: 'categories/:id', component: CategoryDetailsComponent },
{ path: 'categories/:id/edit', component: EditCategoryComponent },
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {})
],
exports: [
RouterModule
],
declarations: []
})
export class AppRoutingModule { }

View file

@ -1,15 +0,0 @@
mat-toolbar {
background-color: #fafafa;
box-shadow: none;
padding-left: 0.5em;
padding-right: 0.5em;
position: sticky;
top: 0;
z-index: 999999;
}
@media (prefers-color-scheme: dark) {
mat-toolbar {
background-color: #303030;
}
}

View file

@ -1,35 +0,0 @@
<p *ngIf="!online" class="error-offline">
You appear to be offline. Twigs unfortunately doesn't currently support offline use at the moment though it may be implemented in a future release!
</p>
<mat-sidenav-container *ngIf="online" class="sidenav-container">
<mat-sidenav #sidenav mode="over" closed>
<mat-nav-list (click)="sidenav.close()" *ngIf="loggedIn">
<a mat-list-item routerLink="">{{ getUsername() }}</a>
<a mat-list-item routerLink="/budgets">Budgets</a>
<a mat-list-item (click)="logout()">Logout</a>
</mat-nav-list>
<mat-nav-list (click)="sidenav.close()" *ngIf="!loggedIn">
<a mat-list-item routerLink="/login">Login</a>
<a mat-list-item routerLink="/register">Register</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<mat-toolbar>
<span>
<a mat-icon-button *ngIf="backEnabled" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
</a>
<a mat-icon-button *ngIf="!backEnabled" (click)="sidenav.open()">
<mat-icon>menu</mat-icon>
</a>
</span>
<span>
{{ title }}
</span>
<span class="action-item">
<a mat-button *ngIf="actionable" (click)="actionable.doAction()">{{ actionable.getActionLabel() }}</a>
</span>
</mat-toolbar>
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>

View file

@ -1,27 +0,0 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
it(`should have as title 'budget'`, waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('budget');
}));
it('should render title in a h1 tag', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to budget!');
}));
});

View file

@ -1,134 +0,0 @@
import { Component, Inject, ApplicationRef, ChangeDetectorRef, OnInit } from '@angular/core';
import { DOCUMENT, Location } from '@angular/common';
import { User } from './users/user';
import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service';
import { SwUpdate } from '@angular/service-worker';
import { first, filter, map } from 'rxjs/operators';
import { interval, concat, BehaviorSubject } from 'rxjs';
import { Router, ActivationEnd, ActivatedRoute } from '@angular/router';
import { Actionable, isActionable } from './shared/actionable';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
public title = 'Twigs';
public backEnabled = false;
public user = new BehaviorSubject<User>(null);
public online = window.navigator.onLine;
public currentVersion = '';
public actionable: Actionable;
public loggedIn = false;
constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private location: Location,
private router: Router,
private appRef: ApplicationRef,
private updates: SwUpdate,
private changeDetector: ChangeDetectorRef,
private storage: Storage,
@Inject(DOCUMENT) private document: Document
) { }
ngOnInit(): void {
const unauthenticatedRoutes = [
'',
'/',
'/login',
'/register'
]
let auth = this.storage.getItem('Authorization');
let userId = this.storage.getItem('userId');
let savedUser = JSON.parse(this.storage.getItem('user')) as User;
if (auth && auth.length == 255 && userId) {
if (savedUser) {
this.user.next(savedUser);
}
this.twigsService.getProfile(userId).then(fetchedUser => {
this.storage.setItem('user', JSON.stringify(fetchedUser));
this.user.next(fetchedUser);
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
//TODO: Save last opened budget and redirect to there instead of the main list
this.router.navigateByUrl("/budgets");
}
});
} else if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
}
this.updates.versionUpdates.subscribe(
event => {
if (event.type == "VERSION_READY") {
console.log('current version is', event.currentVersion);
console.log('available version is', event.latestVersion);
// TODO: Prompt user to click something to update
this.updates.activateUpdate();
}
},
err => {
}
);
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
this.user.subscribe(
user => {
if (user) {
this.loggedIn = true;
} else {
this.loggedIn = false;
}
}
)
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
this.handleDarkModeChanges(darkMode);
darkMode.addEventListener('change', (e => this.handleDarkModeChanges(e)))
}
getUsername(): String {
return this.user.value.username;
}
goBack(): void {
this.location.back();
}
logout(): void {
this.twigsService.logout().then(_ => {
this.location.go('/');
window.location.reload();
});
}
setActionable(actionable: Actionable): void {
this.actionable = actionable;
this.changeDetector.detectChanges();
}
setBackEnabled(enabled: boolean): void {
this.backEnabled = enabled;
this.changeDetector.detectChanges();
}
setTitle(title: string) {
this.title = title;
this.changeDetector.detectChanges();
}
handleDarkModeChanges(darkMode: any) {
const themeColor = this.document.getElementsByName('theme-color')[0] as HTMLMetaElement;
let themeColorValue: string;
if (darkMode.matches) {
themeColorValue = '#333333';
} else {
themeColorValue = '#F1F1F1';
}
themeColor.content = themeColorValue;
}
}

View file

@ -1,108 +0,0 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar';
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { AppComponent } from './app.component';
import { TransactionsComponent } from './transactions/transactions.component';
import { AppRoutingModule } from './app-routing.module';
import { BudgetsComponent } from './budgets/budget.component';
import { TransactionDetailsComponent } from './transactions/transaction-details/transaction-details.component';
import { NewTransactionComponent } from './transactions/new-transaction/new-transaction.component';
import { AddEditTransactionComponent } from './transactions/add-edit-transaction/add-edit-transaction.component';
import { CategoriesComponent } from './categories/categories.component';
import { CategoryDetailsComponent } from './categories/category-details/category-details.component';
import { CategoryFormComponent } from './categories/category-form/category-form.component';
import { NewCategoryComponent } from './categories/new-category/new-category.component';
import { CategoryListComponent } from './categories/category-list/category-list.component';
import { LoginComponent } from './users/login/login.component';
import { RegisterComponent } from './users/register/register.component';
import { AddEditBudgetComponent } from './budgets/add-edit-budget/add-edit-budget.component';
import { EditProfileComponent } from './users/edit-profile/edit-profile.component';
import { UserComponent } from './users/user.component';
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
import { environment } from 'src/environments/environment';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { ServiceWorkerModule } from '@angular/service-worker';
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';
import { NgChartsModule } from 'ng2-charts';
import { TWIGS_SERVICE } from './shared/twigs.service';
import { AuthInterceptor } from './shared/auth.interceptor';
import { TwigsHttpService } from './shared/twigs.http.service';
import { TwigsLocalService } from './shared/twigs.local.service';
import { TransactionListComponent } from './transactions/transaction-list/transaction-list.component';
import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
@NgModule({
declarations: [
AppComponent,
TransactionsComponent,
TransactionDetailsComponent,
NewTransactionComponent,
AddEditTransactionComponent,
CategoriesComponent,
CategoryDetailsComponent,
CategoryFormComponent,
NewCategoryComponent,
CategoryListComponent,
LoginComponent,
RegisterComponent,
AddEditBudgetComponent,
EditProfileComponent,
UserComponent,
NewBudgetComponent,
BudgetDetailsComponent,
BudgetsComponent,
CategoryBreakdownComponent,
TransactionListComponent,
EditCategoryComponent,
EditBudgetComponent,
],
imports: [
BrowserModule,
BrowserAnimationsModule,
MatButtonModule,
MatFormFieldModule,
MatDatepickerModule,
MatIconModule,
MatInputModule,
MatListModule,
MatRadioModule,
MatProgressBarModule,
MatSelectModule,
MatToolbarModule,
MatSidenavModule,
MatProgressSpinnerModule,
AppRoutingModule,
FormsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
HttpClientModule,
NgChartsModule,
MatCheckboxModule,
MatCardModule,
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: TWIGS_SERVICE, useClass: TwigsHttpService },
{ provide: Storage, useValue: window.localStorage },
// { provide: TWIGS_SERVICE, useClass: TwigsLocalService },
],
bootstrap: [AppComponent]
})
export class AppModule { }

View file

@ -1,14 +0,0 @@
<mat-progress-spinner *ngIf="isLoading" mode="indeterminate" diameter="50"></mat-progress-spinner>
<div *ngIf="!isLoading && !budget">
<p>Select a budget from the list to view details about it or edit it.</p>
</div>
<div *ngIf="!isLoading && budget" class="form budget-form">
<mat-form-field>
<input matInput [(ngModel)]="budget.name" placeholder="Name" required autocapitalize="words">
</mat-form-field>
<mat-form-field>
<textarea matInput [(ngModel)]="budget.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field>
<button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddEditBudgetComponent } from './add-edit-budget.component';
describe('AddEditBudgetComponent', () => {
let component: AddEditBudgetComponent;
let fixture: ComponentFixture<AddEditBudgetComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AddEditBudgetComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddEditBudgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,70 +0,0 @@
import { Component, OnInit, Input, Inject, OnDestroy } from '@angular/core';
import { Budget } from '../budget';
import { AppComponent } from 'src/app/app.component';
import { User, UserPermission, Permission } from 'src/app/users/user';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-add-edit-budget',
templateUrl: './add-edit-budget.component.html',
styleUrls: ['./add-edit-budget.component.css']
})
export class AddEditBudgetComponent {
@Input() title: string;
@Input() budget: Budget;
@Input() create: boolean;
public users: UserPermission[];
public searchedUsers: User[] = [];
public isLoading = false;
constructor(
private app: AppComponent,
private router: Router,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) {
this.app.setTitle(this.title)
this.app.setBackEnabled(true);
this.users = [new UserPermission(this.app.user.value.id, Permission.OWNER)];
}
save(): void {
let promise: Promise<Budget>;
this.isLoading = true;
if (this.create) {
// This is a new budget, save it
promise = this.twigsService.createBudget(
this.budget.id,
this.budget.name,
this.budget.description,
this.users
);
} else {
// This is an existing budget, update it
promise = this.twigsService.updateBudget(this.budget.id, this.budget);
}
// TODO: Check if it was actually successful or not
promise.then(_ => {
this.app.goBack();
});
}
delete(): void {
this.isLoading = true;
this.twigsService.deleteBudget(this.budget.id)
.then(() => {
this.router.navigateByUrl("/budgets");
});
}
// TODO: Implement a search box with suggestions to add users
searchUsers(username: string) {
this.twigsService.getUsersByUsername(username).then(users => {
this.searchedUsers = users;
});
}
clearUserSearch() {
this.searchedUsers = [];
}
}

View file

@ -1,111 +0,0 @@
.dashboard {
color: #333333;
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 0 1em;
}
.dashboard>mat-card {
background: #FFFFFF;
display: inline-block;
margin: 1em;
padding: 1em;
max-width: 500px;
position: relative;
width: 100%;
align-self: flex-start;
}
.dashboard .dashboard-primary {
padding: 5em 1em;
text-align: center;
}
.dashboard-primary>* {
display: block;
}
.dashboard div h2,
.dashboard div h3 {
margin: 0;
}
.dashboard p,
.dashboard a {
color: #333333;
text-align: center;
text-decoration: none;
}
.dashboard-primary div {
bottom: 0.5em;
display: flex;
justify-content: flex-end;
left: 0.5em;
right: 0.5em;
position: absolute;
}
.dashboard .no-categories {
padding: 1em;
text-align: center;
}
.dashboard .no-categories a {
border-color: #333333;
display: inline-block;
border: 1px dashed;
padding: 1em;
}
.dashboard .no-categories p {
line-height: normal;
white-space: normal;
}
a.view-all {
position: absolute;
right: 0.5em;
top: 0.5em;
}
@media (min-width: 1160px) {
mat-card {
box-sizing: border-box;
}
.category-info {
height: 313px;
overflow: auto;
}
}
@media (max-width: 610px) {
.dashboard {
padding: 0;
}
.dashboard>mat-card {
margin: 1em auto;
}
}
@media (prefers-color-scheme: dark) {
.dashboard {
color: #F1F1F1;
}
.dashboard>mat-card {
background: #212121;
}
.dashboard p,
.dashboard a {
color: #F1F1F1;
}
.dashboard .no-categories a {
border-color: #F1F1F1;
}
}

View file

@ -1,45 +0,0 @@
<div class="dashboard">
<mat-card class="dashboard-primary" [hidden]="!budget">
<h2 class="balance">
Current Balance: <br />
<span
[ngClass]="{'income': budgetBalance > 0, 'expense': budgetBalance < 0}">{{ budgetBalance / 100 | currency }}</span>
</h2>
<app-category-breakdown [barChartLabels]="barChartLabels" [barChartData]="barChartData">
</app-category-breakdown>
<div class="transaction-navigation">
<a mat-button routerLink="/transactions" [queryParams]="{budgetIds: budget.id}" *ngIf="budget">View Transactions</a>
</div>
</mat-card>
<mat-card class="dashboard-categories" [hidden]="!budget">
<h3 class="categories">Income</h3>
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" class="view-all" *ngIf="budget">Add Category</a>
<div class="no-categories" *ngIf="!income || income.length === 0">
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
<mat-icon>add</mat-icon>
<p>Add categories to gain more insights into your income.</p>
</a>
</div>
<div class="category-info" *ngIf="income && income.length > 0">
<app-category-list [budgetId]="budget.id" [categories]="income" [categoryBalances]="categoryBalances">
</app-category-list>
</div>
</mat-card>
<mat-card class="dashboard-categories" [hidden]="!budget">
<h3 class="categories">Expenses</h3>
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" class="view-all" *ngIf="budget">Add Category</a>
<div class="no-categories" *ngIf="!expenses || expenses.length === 0">
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" *ngIf="budget">
<mat-icon>add</mat-icon>
<p>Add categories to gain more insights into your expenses.</p>
</a>
</div>
<div class="category-info" *ngIf="expenses && expenses.length > 0">
<app-category-list [budgetId]="budget.id" [categories]="expenses" [categoryBalances]="categoryBalances">
</app-category-list>
</div>
</mat-card>
</div>
<a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
<mat-icon aria-label="Add">add</mat-icon>
</a>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BudgetDetailsComponent } from './budget-details.component';
describe('BudgetDetailsComponent', () => {
let component: BudgetDetailsComponent;
let fixture: ComponentFixture<BudgetDetailsComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ BudgetDetailsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BudgetDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,173 +0,0 @@
import { Component, OnInit, Inject, OnDestroy } from '@angular/core';
import { Budget } from '../budget';
import { ActivatedRoute, Router } from '@angular/router';
import { AppComponent } from 'src/app/app.component';
import { Transaction } from 'src/app/transactions/transaction';
import { Category } from 'src/app/categories/category';
import { ChartDataset } from 'chart.js';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { Actionable } from '../../shared/actionable';
@Component({
selector: 'app-budget-details',
templateUrl: './budget-details.component.html',
styleUrls: ['./budget-details.component.css']
})
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
budget: Budget;
public budgetBalance: number;
public transactions: Transaction[];
public expenses: Category[] = [];
public income: Category[] = [];
categoryBalances: Map<string, number>;
expectedIncome = 0;
actualIncome = 0;
expectedExpenses = 0;
actualExpenses = 0;
barChartLabels: string[] = ['Income', 'Expenses'];
barChartData: ChartDataset[] = [
{ data: [0, 0], label: 'Expected' },
{ data: [0, 0], label: 'Actual' },
];
from: Date
to: Date
constructor(
private app: AppComponent,
private route: ActivatedRoute,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private router: Router,
) {
let fromStr = this.route.snapshot.queryParamMap.get('from');
if (fromStr) {
let fromDate = new Date(fromStr);
if (!isNaN(fromDate.getTime())) {
this.from = fromDate;
}
}
if (!this.from) {
let date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setDate(1);
this.from = date;
}
let toStr = this.route.snapshot.queryParamMap.get('to');
if (toStr) {
let toDate = new Date(toStr);
if (!isNaN(toDate.getTime())) {
this.to = toDate;
}
}
}
ngOnInit() {
this.getBudget();
this.app.setBackEnabled(false);
this.app.setActionable(this)
this.categoryBalances = new Map();
}
ngOnDestroy() {
this.app.setActionable(null)
}
getBudget() {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id)
.then(budget => {
this.app.setTitle(budget.name)
this.budget = budget;
this.getBalance();
this.getTransactions();
this.getCategories();
});
}
updateBarChart() {
const color = [0, 188, 212];
this.barChartData = [
{
data: [this.expectedIncome / 100, this.expectedExpenses / 100],
label: 'Expected',
backgroundColor: 'rgba(241, 241, 241, 0.8)',
borderColor: 'rgba(241, 241, 241, 0.9)',
hoverBackgroundColor: 'rgba(241, 241, 241, 1)',
hoverBorderColor: 'rgba(241, 241, 241, 1)',
},
{
data: [this.actualIncome / 100, this.actualExpenses / 100],
label: 'Actual',
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.8)`,
borderColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.9)`,
hoverBackgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`,
hoverBorderColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`
}
];
}
getBalance(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudgetBalance(id, this.from, this.to)
.then(balance => {
this.budgetBalance = balance;
});
}
getTransactions(): void {
let date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setDate(1);
this.twigsService.getTransactions(this.budget.id, null, 5, date)
.then(transactions => this.transactions = <Transaction[]>transactions);
}
async getCategories() {
const categories = await this.twigsService.getCategories(this.budget.id)
const categoryBalances = new Map<string, number>();
let categoryBalancesCount = 0;
for (const category of categories) {
if (category.expense) {
this.expenses.push(category);
this.expectedExpenses += category.amount;
} else {
this.income.push(category);
this.expectedIncome += category.amount;
}
try {
const balance = await this.twigsService.getCategoryBalance(category.id, this.from, this.to)
console.log(balance);
if (category.expense) {
this.actualExpenses += balance * -1;
} else {
this.actualIncome += balance;
}
categoryBalances.set(category.id, balance);
if (categoryBalancesCount === categories.length - 1) {
// This weird workaround is to force the OnChanges callback to be fired.
// Angular needs the reference to the object to change in order for it to
// work.
this.categoryBalances = categoryBalances;
this.updateBarChart();
}
} finally {
categoryBalancesCount++;
}
}
}
doAction(): void {
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
}
getActionLabel(): string {
return "Edit";
}
}

View file

@ -1,27 +0,0 @@
.dashboard {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 1em;
flex-direction: column;
}
.auth-button-container {
display: flex;
flex-grow: 1;
width: 100%;
max-width: 500px;
justify-content: space-between;
padding-top: 1em;
}
.auth-button-container > a {
flex-grow: 1;
margin: 1em;
}
@media all and (max-width: 400px) {
.auth-button-container {
flex-direction: column-reverse;
}
}

View file

@ -1,28 +0,0 @@
<mat-progress-spinner *ngIf="loading" diameter="50" mode="indeterminate"></mat-progress-spinner>
<div class="dashboard" *ngIf="!loading && !loggedIn">
<h2 class="log-in">Welcome to Twigs!</h2>
<p>To begin tracking your finances, login or create an account!</p>
<div class="auth-button-container">
<a routerLink="/register" mat-stroked-button color="accent">Register</a>
<a routerLink="/login" mat-raised-button color="accent">Login</a>
</div>
</div>
<mat-nav-list class="budgets" *ngIf="!loading && loggedIn">
<a mat-list-item *ngFor="let budget of budgets" routerLink="/budgets/{{ budget.id }}">
<p matLine class="budget-list-title">
{{ budget.name }}
</p>
<p matLine class="budget-list-description">
{{ budget.description }}
</p>
</a>
</mat-nav-list>
<div class="no-budgets" *ngIf="!loading && loggedIn && (!budgets || budgets.length === 0)">
<a mat-button routerLink="/budgets/new">
<mat-icon>add</mat-icon>
<p>Add budgets to begin tracking your finances.</p>
</a>
</div>
<a mat-fab routerLink="/budgets/new" *ngIf="!loading && loggedIn">
<mat-icon aria-label="Add">add</mat-icon>
</a>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BudgetsComponent } from './budget.component';
describe('BudgetsComponent', () => {
let component: BudgetsComponent;
let fixture: ComponentFixture<BudgetsComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ BudgetsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BudgetsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,52 +0,0 @@
import { Component, OnInit, Input, Inject, ChangeDetectorRef } from '@angular/core';
import { AppComponent } from '../app.component';
import { Budget } from './budget';
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
@Component({
selector: 'app-budgets',
templateUrl: './budget.component.html',
styleUrls: ['./budget.component.css']
})
export class BudgetsComponent implements OnInit {
public budgets: Budget[];
public loading = true;
public loggedIn = false;
constructor(
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit() {
this.app.setBackEnabled(false);
this.app.user.subscribe(
user => {
if (!user) {
this.loading = false;
this.loggedIn = false;
this.app.setTitle('Welcome')
return;
}
this.app.setTitle('Budgets')
this.loggedIn = true;
this.loading = true;
this.twigsService.getBudgets()
.then(
budgets => {
console.log(budgets)
this.budgets = budgets;
this.loading = false;
})
.catch(error => {
console.log(error)
this.loading = false;
});
},
error => {
this.loading = false;
}
)
}
}

View file

@ -1,9 +0,0 @@
import { UserPermission } from '../users/user';
import { randomId } from '../shared/utils';
export class Budget {
id: string = randomId();
name: string;
description: string;
users: UserPermission[];
}

View file

@ -1 +0,0 @@
<app-add-edit-budget [title]="'Edit Budget'" [budget]="budget" [create]="false"></app-add-edit-budget>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditBudgetComponent } from './edit-budget.component';
describe('EditBudgetComponent', () => {
let component: EditBudgetComponent;
let fixture: ComponentFixture<EditBudgetComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ EditBudgetComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBudgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,27 +0,0 @@
import { Component, OnInit, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TwigsService, TWIGS_SERVICE } from '../../shared/twigs.service';
import { Budget } from '../budget';
@Component({
selector: 'app-edit-budget',
templateUrl: './edit-budget.component.html',
styleUrls: ['./edit-budget.component.css']
})
export class EditBudgetComponent implements OnInit {
budget: Budget;
constructor(
private route: ActivatedRoute,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id)
.then(budget => {
this.budget = budget;
});
}
}

View file

@ -1 +0,0 @@
<app-add-edit-budget [title]="'Add Budget'" [budget]="budget" [create]="true"></app-add-edit-budget>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewBudgetComponent } from './new-budget.component';
describe('NewBudgetComponent', () => {
let component: NewBudgetComponent;
let fixture: ComponentFixture<NewBudgetComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ NewBudgetComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NewBudgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,20 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { Budget } from '../budget';
@Component({
selector: 'app-new-budget',
templateUrl: './new-budget.component.html',
styleUrls: ['./new-budget.component.css']
})
export class NewBudgetComponent implements OnInit {
public budget: Budget;
constructor() {
this.budget = new Budget();
}
ngOnInit() {
}
}

View file

@ -1,4 +0,0 @@
<app-category-list [budgetId]="budgetId" [categories]="categories" [categoryBalances]="categoryBalances"></app-category-list>
<a mat-fab routerLink="/budgets/{{ budgetId }}/categories/new">
<mat-icon aria-label="Add">add</mat-icon>
</a>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoriesComponent } from './categories.component';
describe('CategoriesComponent', () => {
let component: CategoriesComponent;
let fixture: ComponentFixture<CategoriesComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CategoriesComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoriesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,61 +0,0 @@
import { Component, OnInit, Inject } from '@angular/core';
import { Category } from './category';
import { AppComponent } from '../app.component';
import { ActivatedRoute } from '@angular/router';
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
import { Transaction } from '../transactions/transaction';
@Component({
selector: 'app-categories',
templateUrl: './categories.component.html',
styleUrls: ['./categories.component.css']
})
export class CategoriesComponent implements OnInit {
budgetId: string;
public categories: Category[];
public categoryBalances: Map<string, number>;
constructor(
private route: ActivatedRoute,
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit() {
this.budgetId = this.route.snapshot.paramMap.get('budgetId');
this.app.setTitle('Categories')
this.app.setBackEnabled(true);
this.getCategories();
this.categoryBalances = new Map();
}
getCategories(): void {
this.twigsService.getCategories(this.budgetId).then(categories => {
this.categories = categories;
for (const category of this.categories) {
this.getCategoryBalance(category).then(balance => this.categoryBalances.set(category.id, balance));
}
});
}
getCategoryBalance(category: Category): Promise<number> {
return new Promise(async (resolve, reject) => {
let transactions: Transaction[]
try {
transactions = await this.twigsService.getTransactions(this.budgetId, category.id)
} catch(e) {
reject(e)
}
let balance = 0;
for (const transaction of transactions) {
if (transaction.expense) {
balance -= transaction.amount;
} else {
balance += transaction.amount;
}
}
resolve(balance);
});
}
}

View file

@ -1,9 +0,0 @@
<div class="category-breakdown">
<canvas baseChart
[datasets]="barChartData"
[options]="barChartOptions"
[labels]="barChartLabels"
[legend]="barChartLegend"
[type]="barChartType">
</canvas>
</div>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryBreakdownComponent } from './category-breakdown.component';
describe('CategoryBreakdownComponent', () => {
let component: CategoryBreakdownComponent;
let fixture: ComponentFixture<CategoryBreakdownComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CategoryBreakdownComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryBreakdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,47 +0,0 @@
import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core';
import { Category } from '../category';
import { CategoriesComponent } from '../categories.component';
import { ChartConfiguration, ChartType, ChartDataset } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';
@Component({
selector: 'app-category-breakdown',
templateUrl: './category-breakdown.component.html',
styleUrls: ['./category-breakdown.component.css']
})
export class CategoryBreakdownComponent implements OnInit, OnChanges {
barChartOptions: ChartConfiguration['options'] = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
ticks: {
// beginAtZero: true
}
},
y: {}
},
indexAxis: 'y'
};
@Input() barChartLabels: string[];
@Input() barChartData: ChartDataset[] = [
{ data: [0, 0, 0, 0], label: '' },
];
barChartType: ChartType = 'bar';
barChartLegend = true;
@ViewChild(BaseChartDirective) chart: BaseChartDirective;
constructor() { }
ngOnInit() { }
ngOnChanges(changes: SimpleChanges): void {
console.log(changes);
// if (changes.barChartLabels) {
// this.barChartLabels = changes.barChartLabels.currentValue;
// }
if (changes.barChartData) {
this.barChartData = changes.barChartData.currentValue;
}
}
}

View file

@ -1,4 +0,0 @@
.category-description {
padding: 0 1em;
white-space: pre-wrap;
}

View file

@ -1,5 +0,0 @@
<p class="category-description" *ngIf="category && category.description" [innerHtml]="category.description"></p>
<app-transaction-list *ngIf="budgetId && category" [budgetIds]="[budgetId]" [categoryIds]="[category.id]"></app-transaction-list>
<a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budgetId}">
<mat-icon aria-label="Add">add</mat-icon>
</a>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryDetailsComponent } from './category-details.component';
describe('CategoryDetailsComponent', () => {
let component: CategoryDetailsComponent;
let fixture: ComponentFixture<CategoryDetailsComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CategoryDetailsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,55 +0,0 @@
import { Component, OnInit, Inject, ApplicationModule, OnDestroy } from '@angular/core';
import { Category } from '../category';
import { ActivatedRoute, Router } from '@angular/router';
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
import { Transaction } from '../../transactions/transaction';
import { AppComponent } from '../../app.component';
import { Actionable } from '../../shared/actionable';
@Component({
selector: 'app-category-details',
templateUrl: './category-details.component.html',
styleUrls: ['./category-details.component.css']
})
export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
budgetId: string;
category: Category;
public transactions: Transaction[];
constructor(
private route: ActivatedRoute,
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private router: Router
) { }
doAction(): void {
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
}
getActionLabel(): string {
return "Edit";
}
ngOnInit() {
this.app.setBackEnabled(true);
this.app.setActionable(this)
this.getCategory();
}
ngOnDestroy() {
this.app.setActionable(null)
}
getCategory(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getCategory(id)
.then(category => {
category.amount /= 100;
this.app.setTitle(category.title)
this.category = category;
this.budgetId = category.budgetId;
});
}
}

View file

@ -1,11 +0,0 @@
.button-delete {
float: right;
}
.category-form * {
display: block;
}
mat-radio-button {
padding-bottom: 15px;
}

View file

@ -1,26 +0,0 @@
<div *ngIf="!currentCategory">
<p>Select a category from the list to view details about it or edit it.</p>
</div>
<div *ngIf="currentCategory" class="form category-form">
<mat-form-field (keyup.enter)="save()">
<input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required autocapitalize="words">
</mat-form-field>
<mat-form-field (keyup.enter)="save()">
<textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field>
<mat-form-field (keyup.enter)="save()">
<input matInput type="input" [(ngModel)]="currentCategory.amount" placeholder="Amount" required step="0.01">
</mat-form-field>
<mat-radio-group [(ngModel)]="currentCategory.expense">
<mat-radio-button [value]="true">Expense</mat-radio-button>
<mat-radio-button [value]="false">Income</mat-radio-button>
</mat-radio-group>
<mat-checkbox *ngIf="currentCategory.id" [(ngModel)]="currentCategory.archived">Archived</mat-checkbox>
<!--
<mat-form-field>
<input type="color" matInput [(ngModel)]="currentCategory.color" placeholder="Color">
</mat-form-field>
-->
<button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryFormComponent } from './category-form.component';
describe('CategoryFormComponent', () => {
let component: CategoryFormComponent;
let fixture: ComponentFixture<CategoryFormComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CategoryFormComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,62 +0,0 @@
import { Component, OnInit, Input, Inject } from '@angular/core';
import { Category } from '../category';
import { AppComponent } from 'src/app/app.component';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { decimalToInteger } from 'src/app/shared/utils';
@Component({
selector: 'app-category-form',
templateUrl: './category-form.component.html',
styleUrls: ['./category-form.component.css']
})
export class CategoryFormComponent implements OnInit {
@Input() budgetId: string;
@Input() title: string;
@Input() currentCategory: Category;
@Input() create: boolean;
constructor(
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit() {
this.app.setBackEnabled(true);
this.app.setTitle(this.title)
}
save(): void {
let promise;
this.currentCategory.amount = decimalToInteger(String(this.currentCategory.amount))
if (this.create) {
// This is a new category, save it
promise = this.twigsService.createCategory(
this.currentCategory.id,
this.budgetId,
this.currentCategory.title,
this.currentCategory.description,
this.currentCategory.amount,
this.currentCategory.expense
);
} else {
// This is an existing category, update it
const updatedCategory: Category = {
...this.currentCategory,
}
promise = this.twigsService.updateCategory(
this.currentCategory.id,
this.currentCategory
);
}
promise.then(_ => {
this.app.goBack();
});
}
delete(): void {
this.twigsService.deleteCategory(this.currentCategory.id).then(() => {
this.app.goBack();
});
}
}

View file

@ -1,31 +0,0 @@
.categories mat-progress-bar.mat-progress-bar {
margin-top: 0.5em;
}
p.mat-line.category-list-title {
display: flex;
justify-content: space-between;
}
p.mat-line.category-list-title .remaining {
font-size: 0.9em;
font-style: italic;
}
::ng-deep .income .mat-progress-bar-fill::after {
background-color: #81C784 !important;
}
::ng-deep .expense .mat-progress-bar-fill::after {
background-color: #E57373 !important;
}
::ng-deep .mat-progress-bar-buffer {
background-color: #F1F1F1 !important;
}
@media (prefers-color-scheme: dark) {
::ng-deep .mat-progress-bar-buffer {
background-color: #333333 !important;
}
}

View file

@ -1,13 +0,0 @@
<mat-nav-list class="categories">
<a mat-list-item *ngFor="let category of categories" routerLink="/categories/{{ category.id }}">
<p matLine class="category-list-title">
<span>
{{ category.title }}
</span>
<span class="remaining">
{{ getCategoryRemainingBalance(category) | currency }} remaining of {{ category.amount / 100 | currency }}
</span>
</p>
<mat-progress-bar matLine color="accent" [ngClass]="{'income': !category.expense, 'expense': category.expense}" mode="determinate" #categoryProgress [attr.id]="'cat-' + category.id" value="{{ getCategoryCompletion(category) }}"></mat-progress-bar>
</a>
</mat-nav-list>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryListComponent } from './category-list.component';
describe('CategoryListComponent', () => {
let component: CategoryListComponent;
let fixture: ComponentFixture<CategoryListComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CategoryListComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CategoryListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,54 +0,0 @@
import { Component, OnInit, Input } from '@angular/core';
import { Category } from '../category';
@Component({
selector: 'app-category-list',
templateUrl: './category-list.component.html',
styleUrls: ['./category-list.component.css']
})
export class CategoryListComponent implements OnInit {
@Input() budgetId: string;
@Input() categories: Category[];
@Input() categoryBalances: Map<string, number>;
constructor() { }
ngOnInit() {
}
getCategoryRemainingBalance(category: Category): number {
let categoryBalance = this.categoryBalances.get(category.id);
if (!categoryBalance) {
categoryBalance = 0;
}
if (category.expense) {
return (category.amount / 100) + (categoryBalance / 100);
} else {
return (category.amount / 100) - (categoryBalance / 100);
}
}
getCategoryCompletion(category: Category): number {
const amount = category.amount > 0 ? category.amount : 1;
let categoryBalance = this.categoryBalances.get(category.id);
if (!categoryBalance) {
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.
if (category.expense) {
if (categoryBalance < 0) {
categoryBalance = Math.abs(categoryBalance);
} else {
categoryBalance -= (categoryBalance * 2);
}
}
return categoryBalance / amount * 100;
}
}

View file

@ -1,11 +0,0 @@
import { randomId } from '../shared/utils';
export class Category {
id: string = randomId();
title: string;
description: string;
amount: number;
expense: boolean;
archived: boolean;
budgetId: string;
}

View file

@ -1 +0,0 @@
<app-category-form [title]="'Edit Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="false"></app-category-form>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditCategoryComponent } from './edit-category.component';
describe('EditCategoryComponent', () => {
let component: EditCategoryComponent;
let fixture: ComponentFixture<EditCategoryComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ EditCategoryComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditCategoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,38 +0,0 @@
import { Component, OnInit, Inject } from '@angular/core';
import { Category } from '../category';
import { ActivatedRoute } from '@angular/router';
import { AppComponent } from '../../app.component';
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
@Component({
selector: 'app-edit-category',
templateUrl: './edit-category.component.html',
styleUrls: ['./edit-category.component.css']
})
export class EditCategoryComponent implements OnInit {
budgetId: string;
category: Category;
constructor(
private route: ActivatedRoute,
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit(): void {
this.app.setBackEnabled(true);
this.getCategory();
}
getCategory(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getCategory(id)
.then(category => {
category.amount /= 100;
this.app.setTitle(category.title)
this.category = category;
this.budgetId = category.budgetId;
});
}
}

View file

@ -1 +0,0 @@
<app-category-form [title]="'Add Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="true"></app-category-form>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewCategoryComponent } from './new-category.component';
describe('NewCategoryComponent', () => {
let component: NewCategoryComponent;
let fixture: ComponentFixture<NewCategoryComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ NewCategoryComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NewCategoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,27 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { Category } from '../category';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-new-category',
templateUrl: './new-category.component.html',
styleUrls: ['./new-category.component.css']
})
export class NewCategoryComponent implements OnInit {
budgetId: string;
category: Category;
constructor(
private route: ActivatedRoute
) { }
ngOnInit() {
this.budgetId = this.route.snapshot.queryParamMap.get('budgetId');
console.log(`Creating category for budget ${this.budgetId}`)
this.category = new Category();
// TODO: Set random color for category, improve color picker
// this.category.color =
}
}

View file

@ -1,221 +0,0 @@
export class RecurringTransaction {
id: string = '';
title: string;
description?: string = null;
frequency: Frequency;
start: Date = new Date();
end?: Date;
amount: number;
expense = true;
categoryId: string;
budgetId: string;
createdBy: string;
}
export class Frequency {
unit: FrequencyUnit;
count: number;
time: Time;
amount?: (void | Set<DayOfWeek> | DayOfMonth | DayOfYear);
private constructor(unit: FrequencyUnit, count: number, time: Time, amount?: (void | Set<DayOfWeek> | DayOfMonth | DayOfYear)) {
this.unit = unit;
this.count = count;
this.time = time;
this.amount = amount;
}
static Daily(count: number, time: Time): Frequency {
return new Frequency(FrequencyUnit.DAILY, count, time);
}
static Weekly(count: number, time: Time, daysOfWeek: Set<DayOfWeek>): Frequency {
return new Frequency(FrequencyUnit.WEEKLY, count, time, daysOfWeek)
}
static Monthly(count: number, time: Time, dayOfMonth: DayOfMonth): Frequency {
return new Frequency(FrequencyUnit.MONTHLY, count, time, dayOfMonth)
}
static Yearly(count: number, time: Time, dayOfYear: DayOfYear): Frequency {
return new Frequency(FrequencyUnit.YEARLY, count, time, dayOfYear)
}
static parse(s: string): Frequency {
const parts = s.split(';');
let count: number, time: Time;
switch (parts[0]) {
case 'D':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[2]);
return this.Daily(count, time);
case 'W':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const daysOfWeek = new Set(parts[2].split(',').map(day => DayOfWeek[day]));
return this.Weekly(count, time, daysOfWeek);
case 'M':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const dayOfMonth = DayOfMonth.parse(parts[2]);
return this.Monthly(count, time, dayOfMonth);
case 'Y':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const dayOfYear = DayOfYear.parse(parts[2]);
return this.Yearly(count, time, dayOfYear);
default:
throw new Error(`Invalid Frequency format: ${s}`);
}
}
toString(): string {
let parts = [this.unit.toString()]
parts.push(this.count.toString())
if (this.amount) {
if (this.unit === FrequencyUnit.WEEKLY) {
parts.push(Array.from(this.amount as Set<DayOfWeek>).join(','))
} else {
parts.push(this.amount.toString())
}
}
parts.push(this.time.toString())
return parts.join(';')
}
}
export enum FrequencyUnit {
DAILY = 'D',
WEEKLY = 'W',
MONTHLY = 'M',
YEARLY = 'Y',
}
export class Time {
hours: number;
minutes: number;
seconds: number;
constructor(hours: number, minutes: number, seconds: number) {
this.hours = hours;
this.minutes = minutes;
this.seconds = seconds;
}
toString(): string {
return [
String(this.hours).padStart(2, '0'),
String(this.minutes).padStart(2, '0'),
String(this.seconds).padStart(2, '0'),
].join(':')
}
static parse(s: string): Time {
if (!s.match(/[0-9]{2}:[0-9]{2}:[0-9]{2}/)) {
throw new Error('Invalid time format. Time must be formatted as HH:mm:ss');
}
const parts = s.split(':').map(part => Number.parseInt(part));
return new Time(parts[0], parts[1], parts[2]);
}
}
export enum Position {
DAY = 'DAY',
FIRST = 'FIRST',
SECOND = 'SECOND',
THIRD = 'THIRD',
FOURTH = 'FOURTH',
LAST = 'LAST',
}
export enum DayOfWeek {
MONDAY = 'MONDAY',
TUESDAY = 'TUESDAY',
WEDNESDAY = 'WEDNESDAY',
THURSDAY = 'THURSDAY',
FRIDAY = 'FRIDAY',
SATURDAY = 'SATURDAY',
SUNDAY = 'SUNDAY',
}
export class DayOfMonth {
position: Position;
day: (number | DayOfWeek);
private constructor(position: Position, day: (number | DayOfWeek)) {
this.position = position;
this.day = day;
}
static Each(day: number): DayOfMonth {
if (day < 1 || day > 31) {
throw new Error('Day must be between 1 and 31');
}
return new DayOfMonth(Position.DAY, day);
}
static PositionalDayOfWeek(position: Position, day: DayOfWeek): DayOfMonth {
if (position === Position.DAY) {
throw new Error('Use DayOfMonth.Each() to create a monthly recurring transaction on the same calendar day');
}
return new DayOfMonth(position, day)
}
static parse(s: string): DayOfMonth {
const parts = s.split('-');
const position = Position[parts[0]];
if (position === Position.DAY) {
return DayOfMonth.Each(Number.parseInt(parts[1]));
} else {
return DayOfMonth.PositionalDayOfWeek(position, DayOfWeek[parts[1]]);
}
}
toString(): string {
return `${this.position}-${this.day}`
}
}
export class DayOfYear {
month: number;
day: number;
constructor(month: number, day: number) {
this.month = month;
this.day = day;
}
static parse(s: string): DayOfYear {
if (!s.match(/[0-9]{2}-[0-9]{2}/)) {
throw new Error(`Invalid format for DayOfYear: ${s}`)
}
const parts = s.split('-').map(part => Number.parseInt(part));
if (parts[0] < 1 || parts[0] > 12) {
throw new Error(`Invalid month for DayOfYear: ${parts[0]}`);
}
let maxDay: number;
switch (parts[0]) {
case 2:
maxDay = 29;
break;
case 4:
case 6:
case 9:
case 11:
maxDay = 30;
break;
default:
maxDay = 31;
}
if (parts[1] < 1 || parts[1] > maxDay) {
throw new Error(`Invalid day for DayOfYear: ${parts[0]}`);
}
return new DayOfYear(parts[0], parts[1]);
}
toString(): string {
const monthString = this.month.toString().padStart(2, '0')
const dayString = this.day.toString().padStart(2, '0')
return `${monthString}-${dayString}`
}
}

View file

@ -1,9 +0,0 @@
export interface Actionable {
getActionLabel(): string;
doAction(): void;
}
export function isActionable(obj: any): obj is Actionable {
return typeof obj.prototype.getActionLabel === 'function'
&& typeof obj.prototype.doAction === 'function'
}

View file

@ -1,21 +0,0 @@
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(
private storage: Storage
) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
let token = this.storage.getItem('Authorization')
if (!token) {
return next.handle(req);
}
let headers = req.headers;
headers = headers.append('Authorization', `Bearer ${token}`);
return next.handle(req.clone({headers: headers}));
}
}

View file

@ -1,370 +0,0 @@
import { Injectable } from '@angular/core';
import { User, UserPermission, Permission, AuthToken } from '../users/user';
import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction';
import { environment } from '../../environments/environment';
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
@Injectable({
providedIn: 'root'
})
export class TwigsHttpService implements TwigsService {
private apiUrl = environment.apiUrl;
constructor(
private storage: Storage
) { }
async login(email: string, password: string): Promise<User> {
const url = new URL('/api/users/login', this.apiUrl)
const auth: AuthToken = await this.request(url, HttpMethod.POST, {
'username': email,
'password': password
});
this.storage.setItem('Authorization', auth.token);
this.storage.setItem('userId', auth.userId);
return await this.getProfile(auth.userId);
}
register(username: string, email: string, password: string): Promise<User> {
const body = {
'username': username,
'email': email,
'password': password
};
const url = new URL('/api/users/register', this.apiUrl)
return this.request<User>(url, HttpMethod.POST, body);
}
logout(): Promise<void> {
this.storage.removeItem('Authorization');
this.storage.removeItem('userId');
return Promise.resolve()
// TODO: Implement this to revoke the token server-side as well
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
}
// Budgets
getBudgets(): Promise<Budget[]> {
const url = new URL('/api/budgets', this.apiUrl)
return this.request(url, HttpMethod.GET)
}
getBudgetBalance(
id: string,
from?: Date,
to?: Date
): Promise<number> {
const url = new URL('/api/transactions/sum', this.apiUrl)
url.searchParams.set('budgetId', id)
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
return this.request(url, HttpMethod.GET).then((res: any) => res.balance)
}
getBudget(id: string): Promise<Budget> {
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET)
}
createBudget(
id: string,
name: string,
description: string,
users: UserPermission[],
): Promise<Budget> {
const url = new URL('/api/budgets', this.apiUrl)
const body = {
'id': id,
'name': name,
'description': description,
'users': users.map(userPermission => {
return {
user: userPermission.user,
permission: Permission[userPermission.permission]
};
})
};
return this.request(url, HttpMethod.POST, body)
}
updateBudget(id: string, budget: Budget): Promise<Budget> {
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
const body = {
'name': budget.name,
'description': budget.description,
'users': budget.users.map(userPermission => {
return {
user: userPermission.user,
permission: Permission[userPermission.permission]
};
})
};
return this.request(url, HttpMethod.PUT, body)
}
deleteBudget(id: String): Promise<void> {
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
}
// Categories
getCategories(budgetId: string, count?: number): Promise<Category[]> {
const url = new URL(`/api/categories`, this.apiUrl)
url.searchParams.set('budgetIds', budgetId)
url.searchParams.set('archived', 'false')
return this.request(url, HttpMethod.GET);
}
getCategory(id: string): Promise<Category> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET);
}
async getCategoryBalance(
id: string,
from?: Date,
to?: Date
): Promise<number> {
const url = new URL(`/api/transactions/sum`, this.apiUrl)
url.searchParams.set('categoryId', id)
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
const res: any = await this.request(url, HttpMethod.GET);
return res.balance;
}
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
const url = new URL(`/api/categories`, this.apiUrl)
const body = {
'id': id,
'title': name,
'description': description,
'amount': amount,
'expense': isExpense,
'budgetId': budgetId
};
return this.request(url, HttpMethod.POST, body);
}
updateCategory(id: string, changes: object): Promise<Category> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.PUT, changes);
}
deleteCategory(id: string): Promise<void> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE);
}
// Transactions
async getTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<Transaction[]> {
const url = new URL(`/api/transactions`, this.apiUrl)
if (budgetId) {
url.searchParams.set('budgetIds', budgetId);
}
if (categoryId) {
url.searchParams.set('categoryIds', categoryId);
}
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
const transactions: Transaction[] = await this.request(url, HttpMethod.GET)
transactions.forEach(transaction => {
transaction.date = new Date(transaction.date);
})
return transactions
}
async getTransaction(id: string): Promise<Transaction> {
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const transaction: Transaction = await this.request(url, HttpMethod.GET)
transaction.date = new Date(transaction.date)
return transaction
}
async createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
expense: boolean,
category: string
): Promise<Transaction> {
const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id,
'title': name,
'description': description,
'date': date.toISOString(),
'amount': amount,
'expense': expense,
'categoryId': category,
'budgetId': budgetId
};
const transaction: Transaction = await this.request(url, HttpMethod.POST, body)
transaction.date = new Date(transaction.date)
return transaction
}
async updateTransaction(id: string, transaction: Transaction): Promise<Transaction> {
const body: any = transaction;
body.date = transaction.date.toISOString()
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const updatedTransaction: Transaction = await this.request(url, HttpMethod.PUT, body)
updatedTransaction.date = new Date(updatedTransaction.date)
return updatedTransaction
}
deleteTransaction(id: string): Promise<void> {
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
}
// Recurring Transactions
async getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]> {
const url = new URL(`/api/recurringtransactions`, this.apiUrl)
if (budgetId) {
url.searchParams.set('budgetIds', budgetId);
}
if (categoryId) {
url.searchParams.set('categoryIds', categoryId);
}
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
const transactions: RecurringTransaction[] = await this.request(url, HttpMethod.GET)
transactions.forEach(transaction => {
transaction.frequency = Frequency.parse(transaction.frequency as any)
})
return transactions
}
async getRecurringTransaction(id: string): Promise<RecurringTransaction> {
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
const transaction: RecurringTransaction = await this.request(url, HttpMethod.GET)
transaction.frequency = Frequency.parse(transaction.frequency as any)
return transaction
}
async createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction> {
const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id,
'title': name,
'description': description,
'frequency': frequency.toString(),
'start': start.toISOString(),
'finish': end?.toISOString(),
'amount': amount,
'expense': expense,
'categoryId': category,
'budgetId': budgetId
};
const transaction: RecurringTransaction = await this.request(url, HttpMethod.POST, body)
transaction.frequency = Frequency.parse(transaction.frequency as any)
return transaction
}
async updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
const body: any = transaction;
body.frequency = transaction.frequency.toString()
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const updatedTransaction: RecurringTransaction = await this.request(url, HttpMethod.PUT, body)
updatedTransaction.frequency = Frequency.parse(updatedTransaction.frequency as any)
return updatedTransaction
}
deleteRecurringTransaction(id: string): Promise<void> {
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
}
// Users
getProfile(id: string): Promise<User> {
const url = new URL(`/api/users/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET)
}
getUsersByUsername(username: string): Promise<User[]> {
return Promise.reject("Not yet implemented")
}
private async request<T>(url: URL, method: HttpMethod, body?: any): Promise<T> {
const headers = {
'content-type': 'application/json'
}
const token = this.storage.getItem('Authorization')
if (token) {
headers['authorization'] = `Bearer ${token}`
}
let jsonBody: string;
if (body) {
jsonBody = JSON.stringify(body)
}
const res = await fetch(url, {
credentials: 'include',
headers: headers,
method: method,
body: jsonBody
})
if (res.status === 204) {
// No content
return
}
return res.json()
}
}
enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
}

View file

@ -1,348 +0,0 @@
import { Injectable } from '@angular/core';
import { User, UserPermission } from '../users/user';
import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction';
import { randomId } from '../shared/utils';
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
/**
* This is intended to be a very simple implementation of the TwigsService used for testing out the UI and quickly iterating on it.
* It may also prove useful for automated testing.
*/
@Injectable({
providedIn: 'root'
})
export class TwigsLocalService implements TwigsService {
constructor(
) { }
private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
private budgets: Budget[] = [];
private transactions: Transaction[] = [];
private categories: Category[] = [];
// Auth
login(email: string, password: string): Promise<User> {
return new Promise((resolve, reject) => {
const filteredUsers = this.users.filter(user => {
return (user.email === email || user.username === email);
});
if (filteredUsers.length !== 0) {
resolve(filteredUsers[0]);
} else {
reject('No users found');
}
});
}
register(username: string, email: string, password: string): Promise<User> {
return new Promise((resolve, reject) => {
const user = new User();
user.username = username;
user.email = email;
user.id = randomId();
this.users.push(user);
resolve(user);
});
}
logout(): Promise<void> {
return Promise.resolve()
}
// Budgets
getBudgets(): Promise<Budget[]> {
return Promise.resolve(this.budgets)
}
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number> {
return Promise.resolve(200)
}
getBudget(id: string): Promise<Budget> {
return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => {
return it.id === id;
})[0];
if (budget) {
resolve(budget);
} else {
reject('No budget found for given id');
}
});
}
createBudget(
id: string,
name: string,
description: string,
users: UserPermission[],
): Promise<Budget> {
return new Promise((resolve, reject) => {
const budget = new Budget();
budget.name = name;
budget.description = description;
budget.users = users;
budget.id = id;
this.budgets.push(budget);
resolve(budget);
});
}
updateBudget(id: string, budget: Budget): Promise<Budget> {
return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => {
return it.id === id;
})[0];
if (budget) {
const index = this.budgets.indexOf(budget);
this.updateValues(
budget,
budget,
[
'name',
'description',
'users',
]
);
this.budgets[index] = budget;
resolve(budget);
} else {
reject('No budget found for given id');
}
});
}
deleteBudget(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => {
return budget.id === id;
})[0];
if (budget) {
const index = this.budgets.indexOf(budget);
delete this.budgets[index];
resolve();
} else {
reject('No budget found for given id');
}
});
}
// Categories
getCategories(budgetId: string, count?: number): Promise<Category[]> {
return new Promise((resolve, reject) => {
resolve(this.categories.filter(category => {
return category.budgetId === budgetId;
}));
});
}
getCategory(id: string): Promise<Category> {
return new Promise((resolve, reject) => {
resolve(this.findById(this.categories, id));
});
}
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number> {
return Promise.resolve(20);
}
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
return new Promise((resolve, reject) => {
const category = new Category();
category.title = name;
category.description = description;
category.amount = amount;
category.expense = isExpense;
category.budgetId = budgetId;
category.id = id;
this.categories.push(category);
resolve(category);
});
}
updateCategory(id: string, changes: object): Promise<Category> {
return new Promise((resolve, reject) => {
const category = this.findById(this.categories, id);
if (category) {
const index = this.categories.indexOf(category);
this.updateValues(
category,
changes,
[
'name',
'amount',
'isExpense',
'budgetId',
]
);
this.categories[index] = category;
resolve(category);
} else {
reject('No category found for given id');
}
});
}
deleteCategory(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const category = this.findById(this.categories, id);
if (category) {
const index = this.categories.indexOf(category);
delete this.transactions[index];
resolve();
} else {
reject('No category found for given id');
}
});
}
// Transactions
getTransactions(budgetId?: string, categoryId?: string, count?: number): Promise<Transaction[]> {
return new Promise((resolve, reject) => {
resolve(this.transactions.filter(transaction => {
let include = true;
if (budgetId) {
include = transaction.budgetId === budgetId;
}
if (include && categoryId) {
include = transaction.categoryId === categoryId;
}
return include;
}));
});
}
getTransaction(id: string): Promise<Transaction> {
return Promise.resolve(this.findById(this.transactions, id));
}
createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
isExpense: boolean,
category: string
): Promise<Transaction> {
return new Promise((resolve, reject) => {
const transaction = new Transaction();
transaction.title = name;
transaction.description = description;
transaction.amount = amount;
transaction.date = date;
transaction.expense = isExpense;
transaction.categoryId = category;
transaction.budgetId = budgetId;
transaction.id = randomId();
this.transactions.push(transaction);
resolve(transaction);
});
}
updateTransaction(id: string, changes: object): Promise<Transaction> {
return new Promise((resolve, reject) => {
const transaction = this.findById(this.transactions, id);
if (transaction) {
const index = this.transactions.indexOf(transaction);
this.updateValues(
transaction,
changes,
[
'title',
'description',
'date',
'amount',
'isExpense',
'categoryId',
'budgetId',
'createdBy'
]
);
this.transactions[index] = transaction;
resolve(transaction);
} else {
reject('No transaction found for given id');
}
});
}
deleteTransaction(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.findById(this.transactions, id);
if (transaction) {
const index = this.transactions.indexOf(transaction);
delete this.transactions[index];
resolve();
} else {
reject('No transaction found for given id');
}
});
}
// Recurring Transactions
getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]> {
return Promise.reject("Not yet implemented")
}
getRecurringTransaction(id: string): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
deleteRecurringTransaction(id: string): Promise<void> {
return Promise.reject("Not yet implemented")
}
// Users
getProfile(id: string): Promise<User> {
return Promise.reject("Not yet implemented");
}
getUsersByUsername(username: string): Promise<User[]> {
return Promise.resolve(this.users.filter(user => user.username.indexOf(username) > -1))
}
private updateValues(old: object, changes: object, keys: string[]) {
keys.forEach(key => {
if (changes[key]) {
old[key] = changes[key];
}
});
}
private findById<T>(items: T[], id: string): T {
return items.filter(item => {
return item['id'] === id;
})[0];
}
}

View file

@ -1,85 +0,0 @@
import { InjectionToken } from '@angular/core';
import { User, UserPermission } from '../users/user';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { RecurringTransaction, Frequency } from '../recurringtransactions/recurringtransaction';
import { Transaction } from '../transactions/transaction';
export interface TwigsService {
// Auth
login(email: string, password: string): Promise<User>;
register(username: string, email: string, password: string): Promise<User>;
logout(): Promise<void>;
// Budgets
getBudgets(): Promise<Budget[]>;
getBudget(id: string): Promise<Budget>;
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
createBudget(
id: string,
name: string,
description: string,
users: UserPermission[],
): Promise<Budget>;
updateBudget(id: string, budget: Budget): Promise<Budget>;
deleteBudget(id: string): Promise<void>;
// Categories
getCategories(budgetId?: string, count?: number): Promise<Category[]>;
getCategory(id: string): Promise<Category>;
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
updateCategory(id: string, category: Category): Promise<Category>;
deleteCategory(id: string): Promise<void>;
// Transactions
getTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<Transaction[]>;
getTransaction(id: string): Promise<Transaction>;
createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
isExpense: boolean,
category: string
): Promise<Transaction>;
updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
deleteTransaction(id: string): Promise<void>;
// Recurring Transactions
getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]>;
getRecurringTransaction(id: string): Promise<RecurringTransaction>;
createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction>;
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction>;
deleteRecurringTransaction(id: string): Promise<void>;
getProfile(id: string): Promise<User>;
getUsersByUsername(username: string): Promise<User[]>;
}
export let TWIGS_SERVICE = new InjectionToken<TwigsService>('twigs.service');

View file

@ -1,15 +0,0 @@
const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
export function randomId(): string {
var bytes = new Uint8Array(32)
window.crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => CHARACTERS[byte % CHARACTERS.length]).join('')
}
export function decimalToInteger(amount: string): number {
if (amount[amount.length - 3] === "." || amount[amount.length - 3] === ",") {
return Number(amount.replace(/[,.]/g, ""))
} else {
return Number(amount + "00")
}
}

View file

@ -1,11 +0,0 @@
.transaction-form {
padding: 1em;
}
.transaction-form * {
display: block;
}
mat-radio-button {
padding-bottom: 15px;
}

View file

@ -1,34 +0,0 @@
<div [hidden]="currentTransaction">
<p>Select a transaction from the list to view details about it or edit it.</p>
</div>
<div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form">
<mat-form-field>
<input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required autocapitalize="words">
</mat-form-field>
<mat-form-field>
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field>
<mat-form-field>
<input matInput type="number" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required step="0.01">
</mat-form-field>
<mat-form-field>
<input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"
(ngModelChange)="transactionDate = $event" placeholder="Date" required>
</mat-form-field>
<mat-form-field>
<input matInput type="time" [(ngModel)]="currentTime" placeholder="Time" required>
</mat-form-field>
<mat-radio-group [(ngModel)]="currentTransaction.expense" (change)="updateCategories($event)">
<mat-radio-button [value]="true">Expense</mat-radio-button>
<mat-radio-button [value]="false">Income</mat-radio-button>
</mat-radio-group>
<mat-form-field>
<mat-select placeholder="Category" [(ngModel)]="currentTransaction.categoryId">
<mat-option *ngFor="let category of categories" [value]="category.id">
{{ category.title }}
</mat-option>
</mat-select>
</mat-form-field>
<button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddEditTransactionComponent } from './add-edit-transaction.component';
describe('AddEditTransactionComponent', () => {
let component: AddEditTransactionComponent;
let fixture: ComponentFixture<AddEditTransactionComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AddEditTransactionComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddEditTransactionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,107 +0,0 @@
import { Component, OnInit, Input, OnChanges, OnDestroy, Inject, SimpleChanges } from '@angular/core';
import { Transaction } from '../transaction';
import { TransactionType } from '../transaction.type';
import { Category } from 'src/app/categories/category';
import { AppComponent } from 'src/app/app.component';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { MatLegacyRadioChange as MatRadioChange } from '@angular/material/legacy-radio';
import { decimalToInteger } from 'src/app/shared/utils';
@Component({
selector: 'app-add-edit-transaction',
templateUrl: './add-edit-transaction.component.html',
styleUrls: ['./add-edit-transaction.component.css']
})
export class AddEditTransactionComponent implements OnInit, OnChanges {
@Input() title: string;
@Input() currentTransaction: Transaction;
@Input() budgetId: string;
@Input() create: boolean
public transactionType = TransactionType;
public categories: Category[];
public currentTime: string;
public transactionDate: string;
constructor(
private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit() {
this.app.setTitle(this.title)
this.app.setBackEnabled(true);
let d: Date, expense: boolean;
if (this.currentTransaction) {
d = new Date(this.currentTransaction.date);
expense = this.currentTransaction.expense;
} else {
d = new Date();
expense = true;
}
this.updateCategories(new MatRadioChange(undefined, expense));
this.transactionDate = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
this.currentTime = d.toTimeString().slice(0, 5);
}
ngOnChanges(changes: SimpleChanges) {
if (!changes.currentTransaction) {
return;
}
const d = new Date(changes.currentTransaction.currentValue.date * 1000);
this.transactionDate = d.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' });
this.currentTime = d.toLocaleTimeString(undefined, { hour: '2-digit', hour12: false, minute: '2-digit' });
}
updateCategories(change: MatRadioChange) {
this.twigsService.getCategories(this.budgetId)
.then(newCategories => {
this.categories = newCategories.filter(category => category.expense === change.value)
})
}
save(): void {
let promise;
this.currentTransaction.amount = decimalToInteger(String(this.currentTransaction.amount))
this.currentTransaction.date = new Date();
const dateParts = this.transactionDate.split('-');
this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10));
this.currentTransaction.date.setMonth(parseInt(dateParts[1], 10) - 1);
this.currentTransaction.date.setDate(parseInt(dateParts[2], 10));
const timeParts = this.currentTime.split(':');
this.currentTransaction.date.setHours(parseInt(timeParts[0], 10));
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
if (this.create) {
// This is a new transaction, save it
promise = this.twigsService.createTransaction(
this.currentTransaction.id,
this.budgetId,
this.currentTransaction.title,
this.currentTransaction.description,
this.currentTransaction.amount,
this.currentTransaction.date,
this.currentTransaction.expense,
this.currentTransaction.categoryId,
);
} else {
// This is an existing transaction, update it
const updatedTransaction: Transaction = {
...this.currentTransaction,
}
promise = this.twigsService.updateTransaction(
this.currentTransaction.id,
updatedTransaction
);
}
promise.then(() => {
this.app.goBack();
});
}
delete(): void {
this.twigsService.deleteTransaction(this.currentTransaction.id).then(() => {
this.app.goBack();
});
}
}

View file

@ -1 +0,0 @@
<app-add-edit-transaction [budgetId]="budgetId" [title]="'Add Transaction'" [currentTransaction]="transaction" [create]="true"></app-add-edit-transaction>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewTransactionComponent } from './new-transaction.component';
describe('NewTransactionComponent', () => {
let component: NewTransactionComponent;
let fixture: ComponentFixture<NewTransactionComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ NewTransactionComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NewTransactionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,24 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { Transaction } from '../transaction';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-new-transaction',
templateUrl: './new-transaction.component.html',
styleUrls: ['./new-transaction.component.css']
})
export class NewTransactionComponent implements OnInit {
budgetId: string;
transaction: Transaction;
constructor(
private route: ActivatedRoute
) { }
ngOnInit() {
this.budgetId = this.route.snapshot.queryParamMap.get('budgetId');
this.transaction = new Transaction();
}
}

View file

@ -1 +0,0 @@
<app-add-edit-transaction [budgetId]="budgetId" [title]="'Edit Transaction'" [currentTransaction]="transaction" *ngIf="transaction" [create]="false"></app-add-edit-transaction>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TransactionDetailsComponent } from './transaction-details.component';
describe('TransactionDetailsComponent', () => {
let component: TransactionDetailsComponent;
let fixture: ComponentFixture<TransactionDetailsComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ TransactionDetailsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TransactionDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,34 +0,0 @@
import { Component, OnInit, Input, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Transaction } from '../transaction';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
@Component({
selector: 'app-transaction-details',
templateUrl: './transaction-details.component.html',
styleUrls: ['./transaction-details.component.css']
})
export class TransactionDetailsComponent implements OnInit {
budgetId: string;
transaction: Transaction;
constructor(
private route: ActivatedRoute,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit() {
this.getTransaction();
}
getTransaction(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getTransaction(id)
.then(transaction => {
transaction.amount /= 100;
this.transaction = transaction;
this.budgetId = transaction.budgetId;
});
}
}

View file

@ -1,9 +0,0 @@
.transaction-details p {
padding: 2px 0;
}
.transaction-description {
font-style: italic;
text-overflow: ellipsis;
overflow: hidden;
}

View file

@ -1,13 +0,0 @@
<mat-nav-list *ngIf="transactions" class="transactions">
<a mat-list-item class="transaction-list-item" *ngFor="let transaction of transactions"
routerLink="/transactions/{{ transaction.id }}">
<div matLine class="transaction-list-details">
<p class="transaction-title">{{transaction.title}}</p>
<p class="transaction-description text-small" *ngIf="transaction.description">{{transaction.description }}</p>
<p matLine class="transaction-date text-small">{{ transaction.date | date }}</p>
</div>
<p class="amount" [class.expense]="transaction.expense" [class.income]="!transaction.expense">
{{ transaction.amount / 100 | currency }}
</p>
</a>
</mat-nav-list>

View file

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TransactionListComponent } from './transaction-list.component';
describe('TransactionListComponent', () => {
let component: TransactionListComponent;
let fixture: ComponentFixture<TransactionListComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ TransactionListComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TransactionListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -1,60 +0,0 @@
import { Component, OnInit, Input, Inject } from '@angular/core';
import { Transaction } from '../transaction';
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-transaction-list',
templateUrl: './transaction-list.component.html',
styleUrls: ['./transaction-list.component.css']
})
export class TransactionListComponent implements OnInit {
@Input() budgetIds: string[];
@Input() categoryIds?: string[];
public transactions: Transaction[];
constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private route: ActivatedRoute
) { }
ngOnInit(): void {
this.getTransactions();
}
getTransactions(): void {
let fromStr = this.route.snapshot.queryParamMap.get('from');
var from;
if (fromStr) {
let fromDate = new Date(fromStr);
if (!isNaN(fromDate.getTime())) {
from = fromDate;
}
}
if (!from) {
let date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setDate(1);
from = date;
}
let toStr = this.route.snapshot.queryParamMap.get('to');
let to: Date;
if (toStr) {
let toDate = new Date(toStr);
if (!isNaN(toDate.getTime())) {
to = toDate;
}
}
this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to)
.then(transactions => {
this.transactions = transactions;
});
}
}

View file

@ -1,13 +0,0 @@
import { randomId } from '../shared/utils';
export class Transaction {
id: string = randomId();
title: string;
description: string = null;
date: Date = new Date();
amount: number;
expense = true;
categoryId: string;
budgetId: string;
createdBy: string;
}

View file

@ -1,4 +0,0 @@
export enum TransactionType {
INCOME,
EXPENSE
}

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