Compare commits
51 commits
recurring-
...
main
Author | SHA1 | Date | |
---|---|---|---|
c3bded9f24 | |||
|
b2c86452df | ||
|
1f6ab39382 | ||
|
c105e54631 | ||
9498496ba8 | |||
906c943ac8 | |||
0442b6b3d6 | |||
d0150def51 | |||
a38fafb451 | |||
262da7ef92 | |||
674e330290 | |||
86bc2f7c2e | |||
|
40ee188762 | ||
23e0df804e | |||
3957651211 | |||
|
529a420c14 | ||
|
2d5e1f8567 | ||
b6b116863c | |||
d89f615fa0 | |||
90e3f0c02b | |||
d6fbe06cab | |||
484e0c8c75 | |||
|
a858fca6da | ||
|
5654e830a9 | ||
|
67bd92cf9b | ||
|
534db45389 | ||
|
6d66243ee1 | ||
|
c6e6b7904f | ||
|
88022b1074 | ||
6cc063f776 | |||
ec47fc130d | |||
87092be0f9 | |||
|
ba26a378e3 | ||
b3f24049ea | |||
3981e575f2 | |||
170214c1ca | |||
e11ffb741f | |||
447c1894d9 | |||
7fa6f2a1b9 | |||
a7ad95eff8 | |||
9e30452744 | |||
bc58d555c9 | |||
24c74a2dee | |||
9de3a6fd76 | |||
16c9657b80 | |||
84cda20738 | |||
66e5384fe9 | |||
f6178c8848 | |||
4639fa3584 | |||
|
8c27aef40c | ||
b6dfaef44b |
39 changed files with 17250 additions and 13500 deletions
12
.devcontainer/Dockerfile
Normal file
12
.devcontainer/Dockerfile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
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>"
|
23
.devcontainer/devcontainer.json
Normal file
23
.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
// 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"
|
||||||
|
}
|
46
.devcontainer/docker-compose.yml
Normal file
46
.devcontainer/docker-compose.yml
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
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:
|
54
.github/workflows/gh-pages.yml
vendored
Normal file
54
.github/workflows/gh-pages.yml
vendored
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||||
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Build job
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
- name: Install dependencies with npm
|
||||||
|
run: npm ci
|
||||||
|
- name: Build with NPM
|
||||||
|
run: npm run package
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: dist/twigs
|
||||||
|
|
||||||
|
# Deployment job
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"database.connections": [
|
|
||||||
{
|
|
||||||
"type": "mysql",
|
|
||||||
"name": "root@captain.intra.wbrawner.com (MySql)",
|
|
||||||
"host": "captain.intra.wbrawner.com:3306",
|
|
||||||
"username": "root",
|
|
||||||
"database": null,
|
|
||||||
"password": "U7YE8YsmES8LHB2B39WXNjTQk4d48LzQEZG3cj6wSb2fgeRLEYtrrqTwiqAhrpR3"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,5 +1,7 @@
|
||||||
# Twigs Web Client
|
# 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)
|
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
|
## Building
|
||||||
|
@ -44,4 +46,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,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
```
|
```
|
||||||
|
|
|
@ -82,21 +82,21 @@
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "twigs:build"
|
"buildTarget": "twigs:build"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "twigs:build:production"
|
"buildTarget": "twigs:build:production"
|
||||||
},
|
},
|
||||||
"codeserver": {
|
"codeserver": {
|
||||||
"browserTarget": "twigs:build:codeserver"
|
"buildTarget": "twigs:build:codeserver"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "twigs:build"
|
"buildTarget": "twigs:build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
|
@ -138,7 +138,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultProject": "twigs",
|
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": "b8304464-255e-47bb-976a-7ed81af63238"
|
"analytics": "b8304464-255e-47bb-976a-7ed81af63238"
|
||||||
}
|
}
|
||||||
|
|
28999
package-lock.json
generated
28999
package-lock.json
generated
File diff suppressed because it is too large
Load diff
59
package.json
59
package.json
|
@ -3,7 +3,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --host '0.0.0.0'",
|
"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",
|
"code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"package": "ng build --configuration=production --service-worker",
|
"package": "ng build --configuration=production --service-worker",
|
||||||
|
@ -15,45 +15,44 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^13.0.2",
|
"@angular/animations": "^17.2.3",
|
||||||
"@angular/cdk": "^13.0.2",
|
"@angular/cdk": "^17.2.1",
|
||||||
"@angular/common": "^13.0.2",
|
"@angular/common": "^17.2.3",
|
||||||
"@angular/compiler": "^13.0.2",
|
"@angular/compiler": "^17.2.3",
|
||||||
"@angular/core": "^13.0.2",
|
"@angular/core": "^17.2.3",
|
||||||
"@angular/forms": "^13.0.2",
|
"@angular/forms": "^17.2.3",
|
||||||
"@angular/material": "^13.0.2",
|
"@angular/material": "^16.2.0",
|
||||||
"@angular/platform-browser": "^13.0.2",
|
"@angular/platform-browser": "^17.2.3",
|
||||||
"@angular/platform-browser-dynamic": "^13.0.2",
|
"@angular/platform-browser-dynamic": "^17.2.3",
|
||||||
"@angular/router": "^13.0.2",
|
"@angular/router": "^17.2.3",
|
||||||
"@angular/service-worker": "^13.0.2",
|
"@angular/service-worker": "^17.2.3",
|
||||||
"chart.js": "^2.9.3",
|
"chart.js": "^3.7.0",
|
||||||
"core-js": "^3.19.1",
|
"core-js": "^3.20.3",
|
||||||
"ng2-charts": "^2.4.3",
|
"ng2-charts": "^3.0.8",
|
||||||
"rxjs": "^6.3.3",
|
"rxjs": "^7.5.2",
|
||||||
"tslib": "^2.3.1",
|
"tslib": "^2.3.1",
|
||||||
"zone.js": "~0.11.4"
|
"zone.js": "~0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^13.0.3",
|
"@angular-devkit/build-angular": "^17.2.2",
|
||||||
"@angular/cli": "^13.0.3",
|
"@angular/cli": "^17.2.2",
|
||||||
"@angular/compiler-cli": "^13.0.2",
|
"@angular/compiler-cli": "^17.2.3",
|
||||||
"@angular/language-service": "^13.0.2",
|
"@angular/language-service": "^17.2.3",
|
||||||
"@types/jasmine": "~3.10.2",
|
"@types/jasmine": "~3.10.3",
|
||||||
"@types/jasminewd2": "^2.0.10",
|
"@types/jasminewd2": "^2.0.10",
|
||||||
"@types/node": "^16.11.9",
|
"@types/node": "^17.0.10",
|
||||||
"codelyzer": "^6.0.2",
|
"eslint": "^8.7.0",
|
||||||
"eslint": "^8.2.0",
|
"jasmine-core": "~4.0.0",
|
||||||
"jasmine-core": "~3.10.1",
|
|
||||||
"jasmine-spec-reporter": "~7.0.0",
|
"jasmine-spec-reporter": "~7.0.0",
|
||||||
"karma": "~6.3.9",
|
"karma": "~6.3.11",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-chrome-launcher": "~3.1.0",
|
||||||
"karma-coverage-istanbul-reporter": "~3.0.3",
|
"karma-coverage-istanbul-reporter": "~3.0.3",
|
||||||
"karma-jasmine": "~4.0.1",
|
"karma-jasmine": "~4.0.1",
|
||||||
"karma-jasmine-html-reporter": "^1.7.0",
|
"karma-jasmine-html-reporter": "^1.7.0",
|
||||||
"npm-check-updates": "^12.0.2",
|
"npm-check-updates": "^15.0.1",
|
||||||
"protractor": "^7.0.0",
|
"protractor": "^7.0.0",
|
||||||
"ts-node": "~10.4.0",
|
"ts-node": "~10.4.0",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
"typescript": "4.4.4"
|
"typescript": "5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -33,7 +33,7 @@ const routes: Routes = [
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })
|
RouterModule.forRoot(routes, {})
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
RouterModule
|
RouterModule
|
||||||
|
|
|
@ -3,19 +3,21 @@
|
||||||
</p>
|
</p>
|
||||||
<mat-sidenav-container *ngIf="online" class="sidenav-container">
|
<mat-sidenav-container *ngIf="online" class="sidenav-container">
|
||||||
<mat-sidenav #sidenav mode="over" closed>
|
<mat-sidenav #sidenav mode="over" closed>
|
||||||
<mat-nav-list (click)="sidenav.close()">
|
<mat-nav-list (click)="sidenav.close()" *ngIf="loggedIn">
|
||||||
<a mat-list-item *ngIf="loggedIn" routerLink="">{{ getUsername() }}</a>
|
<a mat-list-item routerLink="">{{ getUsername() }}</a>
|
||||||
<a mat-list-item *ngIf="loggedIn" routerLink="/budgets">Budgets</a>
|
<a mat-list-item routerLink="/budgets">Budgets</a>
|
||||||
<a mat-list-item *ngIf="!loggedIn" routerLink="/login">Login</a>
|
<a mat-list-item (click)="logout()">Logout</a>
|
||||||
<a mat-list-item *ngIf="!loggedIn" routerLink="/register">Register</a>
|
</mat-nav-list>
|
||||||
<a mat-list-item *ngIf="loggedIn" (click)="logout()">Logout</a>
|
<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-nav-list>
|
||||||
</mat-sidenav>
|
</mat-sidenav>
|
||||||
<mat-sidenav-content>
|
<mat-sidenav-content>
|
||||||
<mat-toolbar>
|
<mat-toolbar>
|
||||||
<span>
|
<span>
|
||||||
<a mat-icon-button *ngIf="backEnabled" (click)="goBack()">
|
<a mat-icon-button *ngIf="backEnabled" (click)="goBack()">
|
||||||
<mat-icon>arrow_back</mat-icon>
|
<mat-icon>arrow_back</mat-icon>
|
||||||
</a>
|
</a>
|
||||||
<a mat-icon-button *ngIf="!backEnabled" (click)="sidenav.open()">
|
<a mat-icon-button *ngIf="!backEnabled" (click)="sidenav.open()">
|
||||||
<mat-icon>menu</mat-icon>
|
<mat-icon>menu</mat-icon>
|
||||||
|
|
|
@ -9,133 +9,126 @@ import { Router, ActivationEnd, ActivatedRoute } from '@angular/router';
|
||||||
import { Actionable, isActionable } from './shared/actionable';
|
import { Actionable, isActionable } from './shared/actionable';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.css']
|
styleUrls: ['./app.component.css']
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
public title = 'Twigs';
|
public title = 'Twigs';
|
||||||
public backEnabled = false;
|
public backEnabled = false;
|
||||||
public user = new BehaviorSubject<User>(null);
|
public user = new BehaviorSubject<User>(null);
|
||||||
public online = window.navigator.onLine;
|
public online = window.navigator.onLine;
|
||||||
public currentVersion = '';
|
public currentVersion = '';
|
||||||
public actionable: Actionable;
|
public actionable: Actionable;
|
||||||
public loggedIn = false;
|
public loggedIn = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||||
private location: Location,
|
private location: Location,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private appRef: ApplicationRef,
|
private appRef: ApplicationRef,
|
||||||
private updates: SwUpdate,
|
private updates: SwUpdate,
|
||||||
private changeDetector: ChangeDetectorRef,
|
private changeDetector: ChangeDetectorRef,
|
||||||
private storage: Storage,
|
private storage: Storage,
|
||||||
@Inject(DOCUMENT) private document: Document
|
@Inject(DOCUMENT) private document: Document
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const unauthenticatedRoutes = [
|
const unauthenticatedRoutes = [
|
||||||
'',
|
'',
|
||||||
'/',
|
'/',
|
||||||
'/login',
|
'/login',
|
||||||
'/register'
|
'/register'
|
||||||
]
|
]
|
||||||
let auth = this.storage.getItem('Authorization');
|
let auth = this.storage.getItem('Authorization');
|
||||||
let userId = this.storage.getItem('userId');
|
let userId = this.storage.getItem('userId');
|
||||||
let savedUser = JSON.parse(this.storage.getItem('user')) as User;
|
let savedUser = JSON.parse(this.storage.getItem('user')) as User;
|
||||||
if (auth && auth.length == 255 && userId) {
|
if (auth && auth.length == 255 && userId) {
|
||||||
if (savedUser) {
|
if (savedUser) {
|
||||||
this.user.next(savedUser);
|
this.user.next(savedUser);
|
||||||
}
|
}
|
||||||
this.twigsService.getProfile(userId).subscribe(fetchedUser => {
|
this.twigsService.getProfile(userId).then(fetchedUser => {
|
||||||
this.storage.setItem('user', JSON.stringify(fetchedUser));
|
this.storage.setItem('user', JSON.stringify(fetchedUser));
|
||||||
this.user.next(fetchedUser);
|
this.user.next(fetchedUser);
|
||||||
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
|
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
|
||||||
//TODO: Save last opened budget and redirect to there instead of the main list
|
//TODO: Save last opened budget and redirect to there instead of the main list
|
||||||
this.router.navigateByUrl("/budgets");
|
this.router.navigateByUrl("/budgets");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
|
||||||
|
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} else if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
|
this.updates.versionUpdates.subscribe(
|
||||||
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
|
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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updates.available.subscribe(
|
getUsername(): String {
|
||||||
event => {
|
return this.user.value.username;
|
||||||
console.log('current version is', event.current);
|
}
|
||||||
console.log('available version is', event.available);
|
|
||||||
// TODO: Prompt user to click something to update
|
|
||||||
this.updates.activateUpdate();
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
|
|
||||||
}
|
goBack(): void {
|
||||||
);
|
this.location.back();
|
||||||
this.updates.activated.subscribe(
|
}
|
||||||
event => {
|
|
||||||
console.log('old version was', event.previous);
|
|
||||||
console.log('new version is', event.current);
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
|
|
||||||
}
|
logout(): void {
|
||||||
);
|
this.twigsService.logout().then(_ => {
|
||||||
|
this.location.go('/');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
|
setActionable(actionable: Actionable): void {
|
||||||
const everySixHours$ = interval(6 * 60 * 60 * 1000);
|
this.actionable = actionable;
|
||||||
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
|
this.changeDetector.detectChanges();
|
||||||
everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
|
}
|
||||||
this.user.subscribe(
|
|
||||||
user => {
|
setBackEnabled(enabled: boolean): void {
|
||||||
if (user) {
|
this.backEnabled = enabled;
|
||||||
this.loggedIn = true;
|
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 {
|
} else {
|
||||||
this.loggedIn = false;
|
themeColorValue = '#F1F1F1';
|
||||||
}
|
}
|
||||||
}
|
themeColor.content = themeColorValue;
|
||||||
)
|
|
||||||
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().subscribe(_ => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,20 +2,20 @@ import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||||
import { MatListModule } from '@angular/material/list';
|
import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
|
||||||
import { MatRadioModule } from '@angular/material/radio';
|
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { TransactionsComponent } from './transactions/transactions.component';
|
import { TransactionsComponent } from './transactions/transactions.component';
|
||||||
|
@ -40,7 +40,7 @@ import { environment } from 'src/environments/environment';
|
||||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||||
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';
|
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';
|
||||||
import { ChartsModule } from 'ng2-charts';
|
import { NgChartsModule } from 'ng2-charts';
|
||||||
import { TWIGS_SERVICE } from './shared/twigs.service';
|
import { TWIGS_SERVICE } from './shared/twigs.service';
|
||||||
import { AuthInterceptor } from './shared/auth.interceptor';
|
import { AuthInterceptor } from './shared/auth.interceptor';
|
||||||
import { TwigsHttpService } from './shared/twigs.http.service';
|
import { TwigsHttpService } from './shared/twigs.http.service';
|
||||||
|
@ -93,7 +93,7 @@ import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
|
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
ChartsModule,
|
NgChartsModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
],
|
],
|
||||||
|
|
|
@ -4,10 +4,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!isLoading && budget" class="form budget-form">
|
<div *ngIf="!isLoading && budget" class="form budget-form">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input matInput [(ngModel)]="budget.name" placeholder="Name" required>
|
<input matInput [(ngModel)]="budget.name" placeholder="Name" required autocapitalize="words">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<textarea matInput [(ngModel)]="budget.description" placeholder="Description"></textarea>
|
<textarea matInput [(ngModel)]="budget.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button mat-raised-button color="accent" (click)="save()">Save</button>
|
<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>
|
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Budget } from '../budget';
|
||||||
import { AppComponent } from 'src/app/app.component';
|
import { AppComponent } from 'src/app/app.component';
|
||||||
import { User, UserPermission, Permission } from 'src/app/users/user';
|
import { User, UserPermission, Permission } from 'src/app/users/user';
|
||||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-add-edit-budget',
|
selector: 'app-add-edit-budget',
|
||||||
|
@ -19,6 +20,7 @@ export class AddEditBudgetComponent {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private app: AppComponent,
|
private app: AppComponent,
|
||||||
|
private router: Router,
|
||||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||||
) {
|
) {
|
||||||
this.app.setTitle(this.title)
|
this.app.setTitle(this.title)
|
||||||
|
@ -27,11 +29,11 @@ export class AddEditBudgetComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
let observable;
|
let promise: Promise<Budget>;
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (this.create) {
|
if (this.create) {
|
||||||
// This is a new budget, save it
|
// This is a new budget, save it
|
||||||
observable = this.twigsService.createBudget(
|
promise = this.twigsService.createBudget(
|
||||||
this.budget.id,
|
this.budget.id,
|
||||||
this.budget.name,
|
this.budget.name,
|
||||||
this.budget.description,
|
this.budget.description,
|
||||||
|
@ -39,10 +41,10 @@ export class AddEditBudgetComponent {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// This is an existing budget, update it
|
// This is an existing budget, update it
|
||||||
observable = this.twigsService.updateBudget(this.budget.id, this.budget);
|
promise = this.twigsService.updateBudget(this.budget.id, this.budget);
|
||||||
}
|
}
|
||||||
// TODO: Check if it was actually successful or not
|
// TODO: Check if it was actually successful or not
|
||||||
observable.subscribe(val => {
|
promise.then(_ => {
|
||||||
this.app.goBack();
|
this.app.goBack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -50,14 +52,14 @@ export class AddEditBudgetComponent {
|
||||||
delete(): void {
|
delete(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.twigsService.deleteBudget(this.budget.id)
|
this.twigsService.deleteBudget(this.budget.id)
|
||||||
.subscribe(() => {
|
.then(() => {
|
||||||
this.app.goBack();
|
this.router.navigateByUrl("/budgets");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement a search box with suggestions to add users
|
// TODO: Implement a search box with suggestions to add users
|
||||||
searchUsers(username: string) {
|
searchUsers(username: string) {
|
||||||
this.twigsService.getUsersByUsername(username).subscribe(users => {
|
this.twigsService.getUsersByUsername(username).then(users => {
|
||||||
this.searchedUsers = users;
|
this.searchedUsers = users;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AppComponent } from 'src/app/app.component';
|
import { AppComponent } from 'src/app/app.component';
|
||||||
import { Transaction } from 'src/app/transactions/transaction';
|
import { Transaction } from 'src/app/transactions/transaction';
|
||||||
import { Category } from 'src/app/categories/category';
|
import { Category } from 'src/app/categories/category';
|
||||||
import { Observable } from 'rxjs';
|
import { ChartDataset } from 'chart.js';
|
||||||
import { Label } from 'ng2-charts';
|
|
||||||
import { ChartDataSets } from 'chart.js';
|
|
||||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||||
import { Actionable } from '../../shared/actionable';
|
import { Actionable } from '../../shared/actionable';
|
||||||
|
|
||||||
|
@ -16,7 +14,6 @@ import { Actionable } from '../../shared/actionable';
|
||||||
styleUrls: ['./budget-details.component.css']
|
styleUrls: ['./budget-details.component.css']
|
||||||
})
|
})
|
||||||
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
|
|
||||||
budget: Budget;
|
budget: Budget;
|
||||||
public budgetBalance: number;
|
public budgetBalance: number;
|
||||||
public transactions: Transaction[];
|
public transactions: Transaction[];
|
||||||
|
@ -27,18 +24,46 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
actualIncome = 0;
|
actualIncome = 0;
|
||||||
expectedExpenses = 0;
|
expectedExpenses = 0;
|
||||||
actualExpenses = 0;
|
actualExpenses = 0;
|
||||||
barChartLabels: Label[] = ['Income', 'Expenses'];
|
barChartLabels: string[] = ['Income', 'Expenses'];
|
||||||
barChartData: ChartDataSets[] = [
|
barChartData: ChartDataset[] = [
|
||||||
{ data: [0, 0], label: 'Expected' },
|
{ data: [0, 0], label: 'Expected' },
|
||||||
{ data: [0, 0], label: 'Actual' },
|
{ data: [0, 0], label: 'Actual' },
|
||||||
];
|
];
|
||||||
|
from: Date
|
||||||
|
to: Date
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private app: AppComponent,
|
private app: AppComponent,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||||
private router: Router,
|
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() {
|
ngOnInit() {
|
||||||
this.getBudget();
|
this.getBudget();
|
||||||
|
@ -54,7 +79,7 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
getBudget() {
|
getBudget() {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getBudget(id)
|
this.twigsService.getBudget(id)
|
||||||
.subscribe(budget => {
|
.then(budget => {
|
||||||
this.app.setTitle(budget.name)
|
this.app.setTitle(budget.name)
|
||||||
this.budget = budget;
|
this.budget = budget;
|
||||||
this.getBalance();
|
this.getBalance();
|
||||||
|
@ -87,9 +112,10 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
|
|
||||||
getBalance(): void {
|
getBalance(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getBudgetBalance(id).subscribe(balance => {
|
this.twigsService.getBudgetBalance(id, this.from, this.to)
|
||||||
this.budgetBalance = balance;
|
.then(balance => {
|
||||||
});
|
this.budgetBalance = balance;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTransactions(): void {
|
getTransactions(): void {
|
||||||
|
@ -100,53 +126,48 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
date.setMilliseconds(0);
|
date.setMilliseconds(0);
|
||||||
date.setDate(1);
|
date.setDate(1);
|
||||||
this.twigsService.getTransactions(this.budget.id, null, 5, date)
|
this.twigsService.getTransactions(this.budget.id, null, 5, date)
|
||||||
.subscribe(transactions => this.transactions = <Transaction[]>transactions);
|
.then(transactions => this.transactions = <Transaction[]>transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategories(): void {
|
async getCategories() {
|
||||||
this.twigsService.getCategories(this.budget.id).subscribe(categories => {
|
const categories = await this.twigsService.getCategories(this.budget.id)
|
||||||
const categoryBalances = new Map<string, number>();
|
const categoryBalances = new Map<string, number>();
|
||||||
let categoryBalancesCount = 0;
|
let categoryBalancesCount = 0;
|
||||||
console.log(categories);
|
for (const category of categories) {
|
||||||
for (const category of categories) {
|
if (category.expense) {
|
||||||
if (category.expense) {
|
this.expenses.push(category);
|
||||||
this.expenses.push(category);
|
this.expectedExpenses += category.amount;
|
||||||
this.expectedExpenses += category.amount;
|
} else {
|
||||||
} else {
|
this.income.push(category);
|
||||||
this.income.push(category);
|
this.expectedIncome += category.amount;
|
||||||
this.expectedIncome += category.amount;
|
|
||||||
}
|
|
||||||
this.twigsService.getCategoryBalance(category.id).subscribe(
|
|
||||||
balance => {
|
|
||||||
console.log(balance);
|
|
||||||
if (category.expense) {
|
|
||||||
this.actualExpenses += balance * -1;
|
|
||||||
} else {
|
|
||||||
this.actualIncome += balance;
|
|
||||||
}
|
|
||||||
categoryBalances.set(category.id, balance);
|
|
||||||
categoryBalancesCount++;
|
|
||||||
},
|
|
||||||
error => { categoryBalancesCount++; },
|
|
||||||
() => {
|
|
||||||
// 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.
|
|
||||||
if (categoryBalancesCount === categories.length) {
|
|
||||||
this.categoryBalances = categoryBalances;
|
|
||||||
this.updateBarChart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
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 {
|
doAction(): void {
|
||||||
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionLabel(): string {
|
getActionLabel(): string {
|
||||||
return "Edit";
|
return "Edit";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ export class BudgetsComponent implements OnInit {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private app: AppComponent,
|
private app: AppComponent,
|
||||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
@ -32,16 +32,17 @@ export class BudgetsComponent implements OnInit {
|
||||||
this.app.setTitle('Budgets')
|
this.app.setTitle('Budgets')
|
||||||
this.loggedIn = true;
|
this.loggedIn = true;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.twigsService.getBudgets().subscribe(
|
this.twigsService.getBudgets()
|
||||||
budgets => {
|
.then(
|
||||||
console.log(budgets)
|
budgets => {
|
||||||
this.budgets = budgets;
|
console.log(budgets)
|
||||||
|
this.budgets = budgets;
|
||||||
|
this.loading = false;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log(error)
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
});
|
||||||
error => {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
|
@ -20,7 +20,7 @@ export class EditBudgetComponent implements OnInit {
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getBudget(id)
|
this.twigsService.getBudget(id)
|
||||||
.subscribe(budget => {
|
.then(budget => {
|
||||||
this.budget = budget;
|
this.budget = budget;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { Component, OnInit, Input, Inject } from '@angular/core';
|
import { Component, OnInit, Inject } from '@angular/core';
|
||||||
import { Category } from './category';
|
import { Category } from './category';
|
||||||
import { AppComponent } from '../app.component';
|
import { AppComponent } from '../app.component';
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { TransactionType } from '../transactions/transaction.type';
|
|
||||||
import { Budget } from '../budgets/budget';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
|
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
|
||||||
|
import { Transaction } from '../transactions/transaction';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-categories',
|
selector: 'app-categories',
|
||||||
|
@ -33,27 +31,31 @@ export class CategoriesComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategories(): void {
|
getCategories(): void {
|
||||||
this.twigsService.getCategories(this.budgetId).subscribe(categories => {
|
this.twigsService.getCategories(this.budgetId).then(categories => {
|
||||||
this.categories = categories;
|
this.categories = categories;
|
||||||
for (const category of this.categories) {
|
for (const category of this.categories) {
|
||||||
this.getCategoryBalance(category).subscribe(balance => this.categoryBalances.set(category.id, balance));
|
this.getCategoryBalance(category).then(balance => this.categoryBalances.set(category.id, balance));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryBalance(category: Category): Observable<number> {
|
getCategoryBalance(category: Category): Promise<number> {
|
||||||
return Observable.create(subscriber => {
|
return new Promise(async (resolve, reject) => {
|
||||||
this.twigsService.getTransactions(this.budgetId, category.id).subscribe(transactions => {
|
let transactions: Transaction[]
|
||||||
let balance = 0;
|
try {
|
||||||
for (const transaction of transactions) {
|
transactions = await this.twigsService.getTransactions(this.budgetId, category.id)
|
||||||
if (transaction.expense) {
|
} catch(e) {
|
||||||
balance -= transaction.amount;
|
reject(e)
|
||||||
} else {
|
}
|
||||||
balance += transaction.amount;
|
let balance = 0;
|
||||||
}
|
for (const transaction of transactions) {
|
||||||
|
if (transaction.expense) {
|
||||||
|
balance -= transaction.amount;
|
||||||
|
} else {
|
||||||
|
balance += transaction.amount;
|
||||||
}
|
}
|
||||||
subscriber.next(balance);
|
}
|
||||||
});
|
resolve(balance);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
[options]="barChartOptions"
|
[options]="barChartOptions"
|
||||||
[labels]="barChartLabels"
|
[labels]="barChartLabels"
|
||||||
[legend]="barChartLegend"
|
[legend]="barChartLegend"
|
||||||
[chartType]="barChartType">
|
[type]="barChartType">
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
|
@ -1,8 +1,8 @@
|
||||||
import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core';
|
import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core';
|
||||||
import { Category } from '../category';
|
import { Category } from '../category';
|
||||||
import { CategoriesComponent } from '../categories.component';
|
import { CategoriesComponent } from '../categories.component';
|
||||||
import { ChartOptions, ChartType, ChartDataSets } from 'chart.js';
|
import { ChartConfiguration, ChartType, ChartDataset } from 'chart.js';
|
||||||
import { BaseChartDirective, Label } from 'ng2-charts';
|
import { BaseChartDirective } from 'ng2-charts';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-category-breakdown',
|
selector: 'app-category-breakdown',
|
||||||
|
@ -10,22 +10,24 @@ import { BaseChartDirective, Label } from 'ng2-charts';
|
||||||
styleUrls: ['./category-breakdown.component.css']
|
styleUrls: ['./category-breakdown.component.css']
|
||||||
})
|
})
|
||||||
export class CategoryBreakdownComponent implements OnInit, OnChanges {
|
export class CategoryBreakdownComponent implements OnInit, OnChanges {
|
||||||
barChartOptions: ChartOptions = {
|
barChartOptions: ChartConfiguration['options'] = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
scales: {
|
scales: {
|
||||||
xAxes: [{
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
beginAtZero: true
|
// beginAtZero: true
|
||||||
}
|
}
|
||||||
}], yAxes: [{}]
|
},
|
||||||
|
y: {}
|
||||||
},
|
},
|
||||||
|
indexAxis: 'y'
|
||||||
};
|
};
|
||||||
@Input() barChartLabels: Label[];
|
@Input() barChartLabels: string[];
|
||||||
@Input() barChartData: ChartDataSets[] = [
|
@Input() barChartData: ChartDataset[] = [
|
||||||
{ data: [0, 0, 0, 0], label: '' },
|
{ data: [0, 0, 0, 0], label: '' },
|
||||||
];
|
];
|
||||||
barChartType: ChartType = 'horizontalBar';
|
barChartType: ChartType = 'bar';
|
||||||
barChartLegend = true;
|
barChartLegend = true;
|
||||||
@ViewChild(BaseChartDirective) chart: BaseChartDirective;
|
@ViewChild(BaseChartDirective) chart: BaseChartDirective;
|
||||||
|
|
||||||
|
@ -35,9 +37,9 @@ export class CategoryBreakdownComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
console.log(changes);
|
console.log(changes);
|
||||||
if (changes.barChartLabels) {
|
// if (changes.barChartLabels) {
|
||||||
this.barChartLabels = changes.barChartLabels.currentValue;
|
// this.barChartLabels = changes.barChartLabels.currentValue;
|
||||||
}
|
// }
|
||||||
if (changes.barChartData) {
|
if (changes.barChartData) {
|
||||||
this.barChartData = changes.barChartData.currentValue;
|
this.barChartData = changes.barChartData.currentValue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionLabel(): string {
|
getActionLabel(): string {
|
||||||
return "Edit";
|
return "Edit";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||||
getCategory(): void {
|
getCategory(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getCategory(id)
|
this.twigsService.getCategory(id)
|
||||||
.subscribe(category => {
|
.then(category => {
|
||||||
category.amount /= 100;
|
category.amount /= 100;
|
||||||
this.app.setTitle(category.title)
|
this.app.setTitle(category.title)
|
||||||
this.category = category;
|
this.category = category;
|
||||||
|
|
|
@ -2,14 +2,14 @@
|
||||||
<p>Select a category from the list to view details about it or edit it.</p>
|
<p>Select a category from the list to view details about it or edit it.</p>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="currentCategory" class="form category-form">
|
<div *ngIf="currentCategory" class="form category-form">
|
||||||
<mat-form-field (keyup.enter)="doAction()">
|
<mat-form-field (keyup.enter)="save()">
|
||||||
<input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required>
|
<input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required autocapitalize="words">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field (keyup.enter)="doAction()">
|
<mat-form-field (keyup.enter)="save()">
|
||||||
<textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description"></textarea>
|
<textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field (keyup.enter)="doAction()">
|
<mat-form-field (keyup.enter)="save()">
|
||||||
<input matInput type="text" [(ngModel)]="currentCategory.amount" placeholder="Amount" required>
|
<input matInput type="input" [(ngModel)]="currentCategory.amount" placeholder="Amount" required step="0.01">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-radio-group [(ngModel)]="currentCategory.expense">
|
<mat-radio-group [(ngModel)]="currentCategory.expense">
|
||||||
<mat-radio-button [value]="true">Expense</mat-radio-button>
|
<mat-radio-button [value]="true">Expense</mat-radio-button>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { Component, OnInit, Input, OnDestroy, Inject } from '@angular/core';
|
import { Component, OnInit, Input, Inject } from '@angular/core';
|
||||||
import { Category } from '../category';
|
import { Category } from '../category';
|
||||||
import { AppComponent } from 'src/app/app.component';
|
import { AppComponent } from 'src/app/app.component';
|
||||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||||
|
import { decimalToInteger } from 'src/app/shared/utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-category-form',
|
selector: 'app-category-form',
|
||||||
|
@ -26,37 +27,35 @@ export class CategoryFormComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
let observable;
|
let promise;
|
||||||
|
this.currentCategory.amount = decimalToInteger(String(this.currentCategory.amount))
|
||||||
if (this.create) {
|
if (this.create) {
|
||||||
// This is a new category, save it
|
// This is a new category, save it
|
||||||
observable = this.twigsService.createCategory(
|
promise = this.twigsService.createCategory(
|
||||||
this.currentCategory.id,
|
this.currentCategory.id,
|
||||||
this.budgetId,
|
this.budgetId,
|
||||||
this.currentCategory.title,
|
this.currentCategory.title,
|
||||||
this.currentCategory.description,
|
this.currentCategory.description,
|
||||||
this.currentCategory.amount * 100,
|
this.currentCategory.amount,
|
||||||
this.currentCategory.expense
|
this.currentCategory.expense
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// This is an existing category, update it
|
// This is an existing category, update it
|
||||||
observable = this.twigsService.updateCategory(
|
const updatedCategory: Category = {
|
||||||
|
...this.currentCategory,
|
||||||
|
}
|
||||||
|
promise = this.twigsService.updateCategory(
|
||||||
this.currentCategory.id,
|
this.currentCategory.id,
|
||||||
{
|
this.currentCategory
|
||||||
name: this.currentCategory.title,
|
|
||||||
description: this.currentCategory.description,
|
|
||||||
amount: this.currentCategory.amount * 100,
|
|
||||||
expense: this.currentCategory.expense,
|
|
||||||
archived: this.currentCategory.archived
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
observable.subscribe(val => {
|
promise.then(_ => {
|
||||||
this.app.goBack();
|
this.app.goBack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
this.twigsService.deleteCategory(this.currentCategory.id).subscribe(() => {
|
this.twigsService.deleteCategory(this.currentCategory.id).then(() => {
|
||||||
this.app.goBack();
|
this.app.goBack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export class EditCategoryComponent implements OnInit {
|
||||||
getCategory(): void {
|
getCategory(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getCategory(id)
|
this.twigsService.getCategory(id)
|
||||||
.subscribe(category => {
|
.then(category => {
|
||||||
category.amount /= 100;
|
category.amount /= 100;
|
||||||
this.app.setTitle(category.title)
|
this.app.setTitle(category.title)
|
||||||
this.category = category;
|
this.category = category;
|
||||||
|
|
221
src/app/recurringtransactions/recurringtransaction.ts
Normal file
221
src/app/recurringtransactions/recurringtransaction.ts
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,109 +1,77 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
|
|
||||||
import { BehaviorSubject, Observable, pipe, Subscriber } from 'rxjs';
|
|
||||||
import { User, UserPermission, Permission, AuthToken } from '../users/user';
|
import { User, UserPermission, Permission, AuthToken } from '../users/user';
|
||||||
import { TwigsService } from './twigs.service';
|
import { TwigsService } from './twigs.service';
|
||||||
import { Budget } from '../budgets/budget';
|
import { Budget } from '../budgets/budget';
|
||||||
import { Category } from '../categories/category';
|
import { Category } from '../categories/category';
|
||||||
import { Transaction } from '../transactions/transaction';
|
import { Transaction } from '../transactions/transaction';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { map } from 'rxjs/operators';
|
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class TwigsHttpService implements TwigsService {
|
export class TwigsHttpService implements TwigsService {
|
||||||
|
|
||||||
private options = {
|
|
||||||
withCredentials: true
|
|
||||||
};
|
|
||||||
private apiUrl = environment.apiUrl;
|
private apiUrl = environment.apiUrl;
|
||||||
private budgets: BehaviorSubject<Budget[]> = new BehaviorSubject(null);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
|
||||||
private storage: Storage
|
private storage: Storage
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
login(email: string, password: string): Observable<User> {
|
async login(email: string, password: string): Promise<User> {
|
||||||
return new Observable(emitter => {
|
const url = new URL('/api/users/login', this.apiUrl)
|
||||||
const params = {
|
const auth: AuthToken = await this.request(url, HttpMethod.POST, {
|
||||||
'username': email,
|
'username': email,
|
||||||
'password': password
|
'password': password
|
||||||
};
|
|
||||||
this.http.post<AuthToken>(this.apiUrl + '/users/login', params, this.options)
|
|
||||||
.subscribe(
|
|
||||||
auth => {
|
|
||||||
// TODO: Use token expiration to determine cookie expiration
|
|
||||||
this.storage.setItem('Authorization', auth.token);
|
|
||||||
this.storage.setItem('userId', auth.userId);
|
|
||||||
this.getProfile(auth.userId).subscribe(user => emitter.next(user), error => emitter.error(error));
|
|
||||||
},
|
|
||||||
error => emitter.error(error)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
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): Observable<User> {
|
register(username: string, email: string, password: string): Promise<User> {
|
||||||
const params = {
|
const body = {
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'password': password
|
'password': password
|
||||||
};
|
};
|
||||||
return this.http.post<User>(this.apiUrl + '/users/register', params, this.options);
|
const url = new URL('/api/users/register', this.apiUrl)
|
||||||
|
return this.request<User>(url, HttpMethod.POST, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): Observable<void> {
|
logout(): Promise<void> {
|
||||||
return new Observable(emitter => {
|
this.storage.removeItem('Authorization');
|
||||||
this.storage.removeItem('Authorization');
|
this.storage.removeItem('userId');
|
||||||
this.storage.removeItem('userId');
|
return Promise.resolve()
|
||||||
emitter.next();
|
// TODO: Implement this to revoke the token server-side as well
|
||||||
emitter.complete();
|
|
||||||
})
|
|
||||||
// TODO: Implement this when JWT auth is implemented
|
|
||||||
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
|
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budgets
|
// Budgets
|
||||||
getBudgets(): Observable<Budget[]> {
|
getBudgets(): Promise<Budget[]> {
|
||||||
this.http.get<Budget[]>(this.apiUrl + '/budgets', this.options)
|
const url = new URL('/api/budgets', this.apiUrl)
|
||||||
.subscribe(budgets => {
|
return this.request(url, HttpMethod.GET)
|
||||||
this.budgets.next(budgets);
|
|
||||||
});
|
|
||||||
return this.budgets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBudgetBalance(id: string): Observable<number> {
|
getBudgetBalance(
|
||||||
return this.http.get<any>(`${this.apiUrl}/transactions/sum?budgetId=${id}`, this.options)
|
id: string,
|
||||||
.pipe(map(obj => obj.balance));
|
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): Observable<Budget> {
|
getBudget(id: string): Promise<Budget> {
|
||||||
return new Observable(emitter => {
|
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||||
var cachedBudget: Budget
|
return this.request(url, HttpMethod.GET)
|
||||||
if (this.budgets.value) {
|
|
||||||
cachedBudget = this.budgets.value.find(budget => {
|
|
||||||
return budget.id === id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cachedBudget) {
|
|
||||||
emitter.next(cachedBudget);
|
|
||||||
emitter.complete();
|
|
||||||
} else {
|
|
||||||
this.http.get<Budget>(`${this.apiUrl}/budgets/${id}`, this.options)
|
|
||||||
.subscribe(budget => {
|
|
||||||
var oldBudgets = JSON.parse(JSON.stringify(this.budgets.value));
|
|
||||||
if (!oldBudgets) {
|
|
||||||
oldBudgets = [];
|
|
||||||
}
|
|
||||||
oldBudgets.push(budget);
|
|
||||||
oldBudgets.sort();
|
|
||||||
this.budgets.next(oldBudgets);
|
|
||||||
emitter.next(budget);
|
|
||||||
emitter.complete();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createBudget(
|
createBudget(
|
||||||
|
@ -111,8 +79,9 @@ export class TwigsHttpService implements TwigsService {
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
users: UserPermission[],
|
users: UserPermission[],
|
||||||
): Observable<Budget> {
|
): Promise<Budget> {
|
||||||
const params = {
|
const url = new URL('/api/budgets', this.apiUrl)
|
||||||
|
const body = {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
@ -123,19 +92,12 @@ export class TwigsHttpService implements TwigsService {
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
return this.http.post<Budget>(this.apiUrl + '/budgets', params, this.options)
|
return this.request(url, HttpMethod.POST, body)
|
||||||
.pipe(map(budget => {
|
|
||||||
var updatedBudgets = JSON.parse(JSON.stringify(this.budgets.value));
|
|
||||||
updatedBudgets.push(budget);
|
|
||||||
updatedBudgets.sort();
|
|
||||||
this.budgets.next(updatedBudgets);
|
|
||||||
return budget
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBudget(id: string, changes: object): Observable<Budget> {
|
updateBudget(id: string, budget: Budget): Promise<Budget> {
|
||||||
let budget = changes as Budget;
|
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||||
const params = {
|
const body = {
|
||||||
'name': budget.name,
|
'name': budget.name,
|
||||||
'description': budget.description,
|
'description': budget.description,
|
||||||
'users': budget.users.map(userPermission => {
|
'users': budget.users.map(userPermission => {
|
||||||
|
@ -145,55 +107,47 @@ export class TwigsHttpService implements TwigsService {
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
return this.http.put<Budget>(`${this.apiUrl}/budgets/${id}`, params, this.options)
|
return this.request(url, HttpMethod.PUT, body)
|
||||||
.pipe(map(budget => {
|
|
||||||
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
|
|
||||||
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
|
|
||||||
if (index > -1) {
|
|
||||||
updatedBudgets.splice(index, 1);
|
|
||||||
}
|
|
||||||
updatedBudgets.push(budget);
|
|
||||||
updatedBudgets.sort();
|
|
||||||
this.budgets.next(updatedBudgets);
|
|
||||||
return budget
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteBudget(id: String): Observable<void> {
|
deleteBudget(id: String): Promise<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/budgets/${id}`, this.options)
|
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||||
.pipe(map(() => {
|
return this.request(url, HttpMethod.DELETE)
|
||||||
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
|
|
||||||
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
|
|
||||||
if (index > -1) {
|
|
||||||
updatedBudgets.splice(index, 1);
|
|
||||||
}
|
|
||||||
updatedBudgets.sort();
|
|
||||||
this.budgets.next(updatedBudgets);
|
|
||||||
return;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
getCategories(budgetId: string, count?: number): Observable<Category[]> {
|
getCategories(budgetId: string, count?: number): Promise<Category[]> {
|
||||||
const params = {
|
const url = new URL(`/api/categories`, this.apiUrl)
|
||||||
params: new HttpParams()
|
url.searchParams.set('budgetIds', budgetId)
|
||||||
.set('budgetIds', `${budgetId}`)
|
url.searchParams.set('archived', 'false')
|
||||||
.set('archived', false)
|
return this.request(url, HttpMethod.GET);
|
||||||
};
|
|
||||||
return this.http.get<Category[]>(`${this.apiUrl}/categories`, Object.assign(params, this.options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategory(id: string): Observable<Category> {
|
getCategory(id: string): Promise<Category> {
|
||||||
return this.http.get<Category>(`${this.apiUrl}/categories/${id}`, this.options);
|
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||||
|
return this.request(url, HttpMethod.GET);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryBalance(id: string): Observable<number> {
|
async getCategoryBalance(
|
||||||
return this.http.get<any>(`${this.apiUrl}/transactions/sum?categoryId=${id}`, this.options)
|
id: string,
|
||||||
.pipe(map(obj => obj.balance));
|
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): Observable<Category> {
|
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
|
||||||
const params = {
|
const url = new URL(`/api/categories`, this.apiUrl)
|
||||||
|
const body = {
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': name,
|
'title': name,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
@ -201,57 +155,55 @@ export class TwigsHttpService implements TwigsService {
|
||||||
'expense': isExpense,
|
'expense': isExpense,
|
||||||
'budgetId': budgetId
|
'budgetId': budgetId
|
||||||
};
|
};
|
||||||
return this.http.post<Category>(this.apiUrl + '/categories', params, this.options);
|
return this.request(url, HttpMethod.POST, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(id: string, changes: object): Observable<Category> {
|
updateCategory(id: string, changes: object): Promise<Category> {
|
||||||
return this.http.put<Category>(`${this.apiUrl}/categories/${id}`, changes, this.options);
|
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||||
|
return this.request(url, HttpMethod.PUT, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCategory(id: string): Observable<void> {
|
deleteCategory(id: string): Promise<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/categories/${id}`, this.options);
|
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||||
|
return this.request(url, HttpMethod.DELETE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
getTransactions(
|
async getTransactions(
|
||||||
budgetId?: string,
|
budgetId?: string,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
from?: Date,
|
from?: Date,
|
||||||
to?: Date
|
to?: Date
|
||||||
): Observable<Transaction[]> {
|
): Promise<Transaction[]> {
|
||||||
let httpParams = new HttpParams();
|
const url = new URL(`/api/transactions`, this.apiUrl)
|
||||||
if (budgetId) {
|
if (budgetId) {
|
||||||
httpParams = httpParams.set('budgetIds', `${budgetId}`);
|
url.searchParams.set('budgetIds', budgetId);
|
||||||
}
|
}
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
httpParams = httpParams.set('categoryIds', `${categoryId}`);
|
url.searchParams.set('categoryIds', categoryId);
|
||||||
}
|
}
|
||||||
if (from) {
|
if (from) {
|
||||||
httpParams = httpParams.set('from', from.toISOString());
|
url.searchParams.set('from', from.toISOString());
|
||||||
}
|
}
|
||||||
if (to) {
|
if (to) {
|
||||||
httpParams = httpParams.set('to', to.toISOString());
|
url.searchParams.set('to', to.toISOString());
|
||||||
}
|
}
|
||||||
const params = { params: httpParams };
|
const transactions: Transaction[] = await this.request(url, HttpMethod.GET)
|
||||||
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`, Object.assign(params, this.options))
|
transactions.forEach(transaction => {
|
||||||
.pipe(map(transactions => {
|
transaction.date = new Date(transaction.date);
|
||||||
transactions.forEach(transaction => {
|
})
|
||||||
transaction.date = new Date(transaction.date);
|
return transactions
|
||||||
});
|
|
||||||
return transactions;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getTransaction(id: string): Observable<Transaction> {
|
async getTransaction(id: string): Promise<Transaction> {
|
||||||
return this.http.get<Transaction>(`${this.apiUrl}/transactions/${id}`, this.options)
|
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
|
||||||
.pipe(map(transaction => {
|
const transaction: Transaction = await this.request(url, HttpMethod.GET)
|
||||||
transaction.date = new Date(transaction.date);
|
transaction.date = new Date(transaction.date)
|
||||||
return transaction;
|
return transaction
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createTransaction(
|
async createTransaction(
|
||||||
id: string,
|
id: string,
|
||||||
budgetId: string,
|
budgetId: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
@ -260,8 +212,9 @@ export class TwigsHttpService implements TwigsService {
|
||||||
date: Date,
|
date: Date,
|
||||||
expense: boolean,
|
expense: boolean,
|
||||||
category: string
|
category: string
|
||||||
): Observable<Transaction> {
|
): Promise<Transaction> {
|
||||||
const params = {
|
const url = new URL(`/api/transactions`, this.apiUrl)
|
||||||
|
const body = {
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': name,
|
'title': name,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
@ -271,25 +224,147 @@ export class TwigsHttpService implements TwigsService {
|
||||||
'categoryId': category,
|
'categoryId': category,
|
||||||
'budgetId': budgetId
|
'budgetId': budgetId
|
||||||
};
|
};
|
||||||
return this.http.post<Transaction>(this.apiUrl + '/transactions', params, this.options);
|
const transaction: Transaction = await this.request(url, HttpMethod.POST, body)
|
||||||
|
transaction.date = new Date(transaction.date)
|
||||||
|
return transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransaction(id: string, changes: object): Observable<Transaction> {
|
async updateTransaction(id: string, transaction: Transaction): Promise<Transaction> {
|
||||||
return this.http.put<Transaction>(`${this.apiUrl}/transactions/${id}`, changes, this.options);
|
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): Observable<void> {
|
deleteTransaction(id: string): Promise<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/transactions/${id}`, this.options);
|
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
|
// Users
|
||||||
getProfile(id: string): Observable<User> {
|
getProfile(id: string): Promise<User> {
|
||||||
return this.http.get<User>(`${this.apiUrl}/users/${id}`, this.options);
|
const url = new URL(`/api/users/${id}`, this.apiUrl)
|
||||||
|
return this.request(url, HttpMethod.GET)
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsersByUsername(username: string): Observable<User[]> {
|
getUsersByUsername(username: string): Promise<User[]> {
|
||||||
return new Observable(subscriber => {
|
return Promise.reject("Not yet implemented")
|
||||||
subscriber.error("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",
|
||||||
|
}
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
|
|
||||||
import { Observable, Subscriber } from 'rxjs';
|
|
||||||
import { User, UserPermission } from '../users/user';
|
import { User, UserPermission } from '../users/user';
|
||||||
import { TwigsService } from './twigs.service';
|
import { TwigsService } from './twigs.service';
|
||||||
import { Budget } from '../budgets/budget';
|
import { Budget } from '../budgets/budget';
|
||||||
import { Category } from '../categories/category';
|
import { Category } from '../categories/category';
|
||||||
import { Transaction } from '../transactions/transaction';
|
import { Transaction } from '../transactions/transaction';
|
||||||
import { randomId } from '../shared/utils';
|
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.
|
* This is intended to be a very simple implementation of the TwigsService used for testing out the UI and quickly iterating on it.
|
||||||
|
@ -18,7 +17,6 @@ import { randomId } from '../shared/utils';
|
||||||
export class TwigsLocalService implements TwigsService {
|
export class TwigsLocalService implements TwigsService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
|
private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
|
||||||
|
@ -27,63 +25,53 @@ export class TwigsLocalService implements TwigsService {
|
||||||
private categories: Category[] = [];
|
private categories: Category[] = [];
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
login(email: string, password: string): Observable<User> {
|
login(email: string, password: string): Promise<User> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const filteredUsers = this.users.filter(user => {
|
const filteredUsers = this.users.filter(user => {
|
||||||
return (user.email === email || user.username === email);
|
return (user.email === email || user.username === email);
|
||||||
});
|
});
|
||||||
if (filteredUsers.length !== 0) {
|
if (filteredUsers.length !== 0) {
|
||||||
subscriber.next(filteredUsers[0]);
|
resolve(filteredUsers[0]);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No users found');
|
reject('No users found');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
register(username: string, email: string, password: string): Observable<User> {
|
register(username: string, email: string, password: string): Promise<User> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const user = new User();
|
const user = new User();
|
||||||
user.username = username;
|
user.username = username;
|
||||||
user.email = email;
|
user.email = email;
|
||||||
user.id = randomId();
|
user.id = randomId();
|
||||||
this.users.push(user);
|
this.users.push(user);
|
||||||
subscriber.next(user);
|
resolve(user);
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): Observable<void> {
|
logout(): Promise<void> {
|
||||||
return new Observable(subscriber => {
|
return Promise.resolve()
|
||||||
subscriber.complete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budgets
|
// Budgets
|
||||||
getBudgets(): Observable<Budget[]> {
|
getBudgets(): Promise<Budget[]> {
|
||||||
return new Observable(subscriber => {
|
return Promise.resolve(this.budgets)
|
||||||
subscriber.next(this.budgets);
|
|
||||||
subscriber.complete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBudgetBalance(id: string): Observable<number> {
|
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number> {
|
||||||
return new Observable(emitter => {
|
return Promise.resolve(200)
|
||||||
emitter.next(200);
|
|
||||||
emitter.complete()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBudget(id: string): Observable<Budget> {
|
getBudget(id: string): Promise<Budget> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const budget = this.budgets.filter(it => {
|
const budget = this.budgets.filter(it => {
|
||||||
return it.id === id;
|
return it.id === id;
|
||||||
})[0];
|
})[0];
|
||||||
if (budget) {
|
if (budget) {
|
||||||
subscriber.next(budget);
|
resolve(budget);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No budget found for given id');
|
reject('No budget found for given id');
|
||||||
}
|
}
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,21 +80,20 @@ export class TwigsLocalService implements TwigsService {
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
users: UserPermission[],
|
users: UserPermission[],
|
||||||
): Observable<Budget> {
|
): Promise<Budget> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const budget = new Budget();
|
const budget = new Budget();
|
||||||
budget.name = name;
|
budget.name = name;
|
||||||
budget.description = description;
|
budget.description = description;
|
||||||
budget.users = users;
|
budget.users = users;
|
||||||
budget.id = id;
|
budget.id = id;
|
||||||
this.budgets.push(budget);
|
this.budgets.push(budget);
|
||||||
subscriber.next(budget);
|
resolve(budget);
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBudget(id: string, changes: object): Observable<Budget> {
|
updateBudget(id: string, budget: Budget): Promise<Budget> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const budget = this.budgets.filter(it => {
|
const budget = this.budgets.filter(it => {
|
||||||
return it.id === id;
|
return it.id === id;
|
||||||
})[0];
|
})[0];
|
||||||
|
@ -114,7 +101,7 @@ export class TwigsLocalService implements TwigsService {
|
||||||
const index = this.budgets.indexOf(budget);
|
const index = this.budgets.indexOf(budget);
|
||||||
this.updateValues(
|
this.updateValues(
|
||||||
budget,
|
budget,
|
||||||
changes,
|
budget,
|
||||||
[
|
[
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
|
@ -122,55 +109,49 @@ export class TwigsLocalService implements TwigsService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
this.budgets[index] = budget;
|
this.budgets[index] = budget;
|
||||||
subscriber.next(budget);
|
resolve(budget);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No budget found for given id');
|
reject('No budget found for given id');
|
||||||
}
|
}
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteBudget(id: string): Observable<void> {
|
deleteBudget(id: string): Promise<void> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const budget = this.budgets.filter(it => {
|
const budget = this.budgets.filter(it => {
|
||||||
return budget.id === id;
|
return budget.id === id;
|
||||||
})[0];
|
})[0];
|
||||||
if (budget) {
|
if (budget) {
|
||||||
const index = this.budgets.indexOf(budget);
|
const index = this.budgets.indexOf(budget);
|
||||||
delete this.budgets[index];
|
delete this.budgets[index];
|
||||||
subscriber.complete();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No budget found for given id');
|
reject('No budget found for given id');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
getCategories(budgetId: string, count?: number): Observable<Category[]> {
|
getCategories(budgetId: string, count?: number): Promise<Category[]> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
subscriber.next(this.categories.filter(category => {
|
resolve(this.categories.filter(category => {
|
||||||
return category.budgetId === budgetId;
|
return category.budgetId === budgetId;
|
||||||
}));
|
}));
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategory(id: string): Observable<Category> {
|
getCategory(id: string): Promise<Category> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
subscriber.next(this.findById(this.categories, id));
|
resolve(this.findById(this.categories, id));
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryBalance(id: string): Observable<number> {
|
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number> {
|
||||||
return new Observable(emitter => {
|
return Promise.resolve(20);
|
||||||
emitter.next(20);
|
|
||||||
emitter.complete()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category> {
|
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
|
||||||
return Observable.create(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const category = new Category();
|
const category = new Category();
|
||||||
category.title = name;
|
category.title = name;
|
||||||
category.description = description;
|
category.description = description;
|
||||||
|
@ -179,13 +160,12 @@ export class TwigsLocalService implements TwigsService {
|
||||||
category.budgetId = budgetId;
|
category.budgetId = budgetId;
|
||||||
category.id = id;
|
category.id = id;
|
||||||
this.categories.push(category);
|
this.categories.push(category);
|
||||||
subscriber.next(category);
|
resolve(category);
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(id: string, changes: object): Observable<Category> {
|
updateCategory(id: string, changes: object): Promise<Category> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const category = this.findById(this.categories, id);
|
const category = this.findById(this.categories, id);
|
||||||
if (category) {
|
if (category) {
|
||||||
const index = this.categories.indexOf(category);
|
const index = this.categories.indexOf(category);
|
||||||
|
@ -200,31 +180,30 @@ export class TwigsLocalService implements TwigsService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
this.categories[index] = category;
|
this.categories[index] = category;
|
||||||
subscriber.next(category);
|
resolve(category);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No category found for given id');
|
reject('No category found for given id');
|
||||||
}
|
}
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCategory(id: string): Observable<void> {
|
deleteCategory(id: string): Promise<void> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const category = this.findById(this.categories, id);
|
const category = this.findById(this.categories, id);
|
||||||
if (category) {
|
if (category) {
|
||||||
const index = this.categories.indexOf(category);
|
const index = this.categories.indexOf(category);
|
||||||
delete this.transactions[index];
|
delete this.transactions[index];
|
||||||
subscriber.complete();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No category found for given id');
|
reject('No category found for given id');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
getTransactions(budgetId?: string, categoryId?: string, count?: number): Observable<Transaction[]> {
|
getTransactions(budgetId?: string, categoryId?: string, count?: number): Promise<Transaction[]> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
subscriber.next(this.transactions.filter(transaction => {
|
resolve(this.transactions.filter(transaction => {
|
||||||
let include = true;
|
let include = true;
|
||||||
if (budgetId) {
|
if (budgetId) {
|
||||||
include = transaction.budgetId === budgetId;
|
include = transaction.budgetId === budgetId;
|
||||||
|
@ -234,15 +213,11 @@ export class TwigsLocalService implements TwigsService {
|
||||||
}
|
}
|
||||||
return include;
|
return include;
|
||||||
}));
|
}));
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTransaction(id: string): Observable<Transaction> {
|
getTransaction(id: string): Promise<Transaction> {
|
||||||
return new Observable(subscriber => {
|
return Promise.resolve(this.findById(this.transactions, id));
|
||||||
subscriber.next(this.findById(this.transactions, id));
|
|
||||||
subscriber.complete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createTransaction(
|
createTransaction(
|
||||||
|
@ -254,8 +229,8 @@ export class TwigsLocalService implements TwigsService {
|
||||||
date: Date,
|
date: Date,
|
||||||
isExpense: boolean,
|
isExpense: boolean,
|
||||||
category: string
|
category: string
|
||||||
): Observable<Transaction> {
|
): Promise<Transaction> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = new Transaction();
|
const transaction = new Transaction();
|
||||||
transaction.title = name;
|
transaction.title = name;
|
||||||
transaction.description = description;
|
transaction.description = description;
|
||||||
|
@ -266,13 +241,12 @@ export class TwigsLocalService implements TwigsService {
|
||||||
transaction.budgetId = budgetId;
|
transaction.budgetId = budgetId;
|
||||||
transaction.id = randomId();
|
transaction.id = randomId();
|
||||||
this.transactions.push(transaction);
|
this.transactions.push(transaction);
|
||||||
subscriber.next(transaction);
|
resolve(transaction);
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransaction(id: string, changes: object): Observable<Transaction> {
|
updateTransaction(id: string, changes: object): Promise<Transaction> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.findById(this.transactions, id);
|
const transaction = this.findById(this.transactions, id);
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const index = this.transactions.indexOf(transaction);
|
const index = this.transactions.indexOf(transaction);
|
||||||
|
@ -291,38 +265,71 @@ export class TwigsLocalService implements TwigsService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
this.transactions[index] = transaction;
|
this.transactions[index] = transaction;
|
||||||
subscriber.next(transaction);
|
resolve(transaction);
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No transaction found for given id');
|
reject('No transaction found for given id');
|
||||||
}
|
}
|
||||||
subscriber.complete();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTransaction(id: string): Observable<void> {
|
deleteTransaction(id: string): Promise<void> {
|
||||||
return new Observable(subscriber => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.findById(this.transactions, id);
|
const transaction = this.findById(this.transactions, id);
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const index = this.transactions.indexOf(transaction);
|
const index = this.transactions.indexOf(transaction);
|
||||||
delete this.transactions[index];
|
delete this.transactions[index];
|
||||||
subscriber.complete();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
subscriber.error('No transaction found for given id');
|
reject('No transaction found for given id');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users
|
// Recurring Transactions
|
||||||
getProfile(id: string): Observable<User> {
|
getRecurringTransactions(
|
||||||
return new Observable(subscriber => {
|
budgetId?: string,
|
||||||
subscriber.error("Not yet implemented")
|
categoryId?: string,
|
||||||
});
|
count?: number,
|
||||||
|
from?: Date,
|
||||||
|
to?: Date
|
||||||
|
): Promise<RecurringTransaction[]> {
|
||||||
|
return Promise.reject("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsersByUsername(username: string): Observable<User[]> {
|
getRecurringTransaction(id: string): Promise<RecurringTransaction> {
|
||||||
return new Observable(subscriber => {
|
return Promise.reject("Not yet implemented")
|
||||||
subscriber.next(this.users.filter(user => user.username.indexOf(username) > -1 ));
|
}
|
||||||
});
|
|
||||||
|
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[]) {
|
private updateValues(old: object, changes: object, keys: string[]) {
|
||||||
|
|
|
@ -1,36 +1,36 @@
|
||||||
import { InjectionToken } from '@angular/core';
|
import { InjectionToken } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { User, UserPermission } from '../users/user';
|
import { User, UserPermission } from '../users/user';
|
||||||
import { Budget } from '../budgets/budget';
|
import { Budget } from '../budgets/budget';
|
||||||
import { Category } from '../categories/category';
|
import { Category } from '../categories/category';
|
||||||
|
import { RecurringTransaction, Frequency } from '../recurringtransactions/recurringtransaction';
|
||||||
import { Transaction } from '../transactions/transaction';
|
import { Transaction } from '../transactions/transaction';
|
||||||
|
|
||||||
export interface TwigsService {
|
export interface TwigsService {
|
||||||
// Auth
|
// Auth
|
||||||
login(email: string, password: string): Observable<User>;
|
login(email: string, password: string): Promise<User>;
|
||||||
register(username: string, email: string, password: string): Observable<User>;
|
register(username: string, email: string, password: string): Promise<User>;
|
||||||
logout(): Observable<void>;
|
logout(): Promise<void>;
|
||||||
|
|
||||||
// Budgets
|
// Budgets
|
||||||
getBudgets(): Observable<Budget[]>;
|
getBudgets(): Promise<Budget[]>;
|
||||||
getBudget(id: string): Observable<Budget>;
|
getBudget(id: string): Promise<Budget>;
|
||||||
getBudgetBalance(id: string): Observable<number>;
|
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
|
||||||
createBudget(
|
createBudget(
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
users: UserPermission[],
|
users: UserPermission[],
|
||||||
): Observable<Budget>;
|
): Promise<Budget>;
|
||||||
updateBudget(id: string, changes: object): Observable<Budget>;
|
updateBudget(id: string, budget: Budget): Promise<Budget>;
|
||||||
deleteBudget(id: string): Observable<void>;
|
deleteBudget(id: string): Promise<void>;
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
getCategories(budgetId?: string, count?: number): Observable<Category[]>;
|
getCategories(budgetId?: string, count?: number): Promise<Category[]>;
|
||||||
getCategory(id: string): Observable<Category>;
|
getCategory(id: string): Promise<Category>;
|
||||||
getCategoryBalance(id: string): Observable<number>;
|
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
|
||||||
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category>;
|
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
|
||||||
updateCategory(id: string, changes: object): Observable<Category>;
|
updateCategory(id: string, category: Category): Promise<Category>;
|
||||||
deleteCategory(id: string): Observable<void>;
|
deleteCategory(id: string): Promise<void>;
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
getTransactions(
|
getTransactions(
|
||||||
|
@ -39,8 +39,8 @@ export interface TwigsService {
|
||||||
count?: number,
|
count?: number,
|
||||||
from?: Date,
|
from?: Date,
|
||||||
to?: Date
|
to?: Date
|
||||||
): Observable<Transaction[]>;
|
): Promise<Transaction[]>;
|
||||||
getTransaction(id: string): Observable<Transaction>;
|
getTransaction(id: string): Promise<Transaction>;
|
||||||
createTransaction(
|
createTransaction(
|
||||||
id: string,
|
id: string,
|
||||||
budgetId: string,
|
budgetId: string,
|
||||||
|
@ -50,12 +50,36 @@ export interface TwigsService {
|
||||||
date: Date,
|
date: Date,
|
||||||
isExpense: boolean,
|
isExpense: boolean,
|
||||||
category: string
|
category: string
|
||||||
): Observable<Transaction>;
|
): Promise<Transaction>;
|
||||||
updateTransaction(id: string, changes: object): Observable<Transaction>;
|
updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
|
||||||
deleteTransaction(id: string): Observable<void>;
|
deleteTransaction(id: string): Promise<void>;
|
||||||
|
|
||||||
getProfile(id: string): Observable<User>;
|
// Recurring Transactions
|
||||||
getUsersByUsername(username: string): Observable<User[]>;
|
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');
|
export let TWIGS_SERVICE = new InjectionToken<TwigsService>('twigs.service');
|
||||||
|
|
|
@ -5,3 +5,11 @@ export function randomId(): string {
|
||||||
window.crypto.getRandomValues(bytes)
|
window.crypto.getRandomValues(bytes)
|
||||||
return Array.from(bytes, (byte) => CHARACTERS[byte % CHARACTERS.length]).join('')
|
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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,13 +3,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form">
|
<div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required>
|
<input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required autocapitalize="words">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description"></textarea>
|
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input matInput type="text" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required>
|
<input matInput type="number" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required step="0.01">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"
|
<input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { TransactionType } from '../transaction.type';
|
||||||
import { Category } from 'src/app/categories/category';
|
import { Category } from 'src/app/categories/category';
|
||||||
import { AppComponent } from 'src/app/app.component';
|
import { AppComponent } from 'src/app/app.component';
|
||||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||||
import { MatRadioChange } from '@angular/material/radio';
|
import { MatLegacyRadioChange as MatRadioChange } from '@angular/material/legacy-radio';
|
||||||
|
import { decimalToInteger } from 'src/app/shared/utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-add-edit-transaction',
|
selector: 'app-add-edit-transaction',
|
||||||
|
@ -18,7 +19,6 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
|
||||||
@Input() create: boolean
|
@Input() create: boolean
|
||||||
public transactionType = TransactionType;
|
public transactionType = TransactionType;
|
||||||
public categories: Category[];
|
public categories: Category[];
|
||||||
public rawAmount: string;
|
|
||||||
public currentTime: string;
|
public currentTime: string;
|
||||||
public transactionDate: string;
|
public transactionDate: string;
|
||||||
|
|
||||||
|
@ -55,15 +55,14 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
updateCategories(change: MatRadioChange) {
|
updateCategories(change: MatRadioChange) {
|
||||||
this.twigsService.getCategories(this.budgetId)
|
this.twigsService.getCategories(this.budgetId)
|
||||||
.subscribe(newCategories => {
|
.then(newCategories => {
|
||||||
this.categories = newCategories.filter(category => category.expense === change.value)
|
this.categories = newCategories.filter(category => category.expense === change.value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
// The amount will be input as a decimal value so we need to convert it
|
let promise;
|
||||||
// to an integer
|
this.currentTransaction.amount = decimalToInteger(String(this.currentTransaction.amount))
|
||||||
let observable;
|
|
||||||
this.currentTransaction.date = new Date();
|
this.currentTransaction.date = new Date();
|
||||||
const dateParts = this.transactionDate.split('-');
|
const dateParts = this.transactionDate.split('-');
|
||||||
this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10));
|
this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10));
|
||||||
|
@ -74,38 +73,34 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
|
||||||
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
|
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
|
||||||
if (this.create) {
|
if (this.create) {
|
||||||
// This is a new transaction, save it
|
// This is a new transaction, save it
|
||||||
observable = this.twigsService.createTransaction(
|
promise = this.twigsService.createTransaction(
|
||||||
this.currentTransaction.id,
|
this.currentTransaction.id,
|
||||||
this.budgetId,
|
this.budgetId,
|
||||||
this.currentTransaction.title,
|
this.currentTransaction.title,
|
||||||
this.currentTransaction.description,
|
this.currentTransaction.description,
|
||||||
Math.round(this.currentTransaction.amount * 100),
|
this.currentTransaction.amount,
|
||||||
this.currentTransaction.date,
|
this.currentTransaction.date,
|
||||||
this.currentTransaction.expense,
|
this.currentTransaction.expense,
|
||||||
this.currentTransaction.categoryId,
|
this.currentTransaction.categoryId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// This is an existing transaction, update it
|
// This is an existing transaction, update it
|
||||||
observable = this.twigsService.updateTransaction(
|
const updatedTransaction: Transaction = {
|
||||||
|
...this.currentTransaction,
|
||||||
|
}
|
||||||
|
promise = this.twigsService.updateTransaction(
|
||||||
this.currentTransaction.id,
|
this.currentTransaction.id,
|
||||||
{
|
updatedTransaction
|
||||||
title: this.currentTransaction.title,
|
|
||||||
description: this.currentTransaction.description,
|
|
||||||
amount: Math.round(this.currentTransaction.amount * 100),
|
|
||||||
date: this.currentTransaction.date,
|
|
||||||
categoryId: this.currentTransaction.categoryId,
|
|
||||||
expense: this.currentTransaction.expense
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
observable.subscribe(val => {
|
promise.then(() => {
|
||||||
this.app.goBack();
|
this.app.goBack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
this.twigsService.deleteTransaction(this.currentTransaction.id).subscribe(() => {
|
this.twigsService.deleteTransaction(this.currentTransaction.id).then(() => {
|
||||||
this.app.goBack();
|
this.app.goBack();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export class TransactionDetailsComponent implements OnInit {
|
||||||
getTransaction(): void {
|
getTransaction(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
this.twigsService.getTransaction(id)
|
this.twigsService.getTransaction(id)
|
||||||
.subscribe(transaction => {
|
.then(transaction => {
|
||||||
transaction.amount /= 100;
|
transaction.amount /= 100;
|
||||||
this.transaction = transaction;
|
this.transaction = transaction;
|
||||||
this.budgetId = transaction.budgetId;
|
this.budgetId = transaction.budgetId;
|
||||||
|
|
|
@ -44,7 +44,7 @@ export class TransactionListComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
let toStr = this.route.snapshot.queryParamMap.get('to');
|
let toStr = this.route.snapshot.queryParamMap.get('to');
|
||||||
var to;
|
let to: Date;
|
||||||
if (toStr) {
|
if (toStr) {
|
||||||
let toDate = new Date(toStr);
|
let toDate = new Date(toStr);
|
||||||
if (!isNaN(toDate.getTime())) {
|
if (!isNaN(toDate.getTime())) {
|
||||||
|
@ -52,7 +52,8 @@ export class TransactionListComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to).subscribe(transactions => {
|
this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to)
|
||||||
|
.then(transactions => {
|
||||||
this.transactions = transactions;
|
this.transactions = transactions;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,13 +32,13 @@ export class LoginComponent implements OnInit {
|
||||||
login(): void {
|
login(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.twigsService.login(this.email, this.password)
|
this.twigsService.login(this.email, this.password)
|
||||||
.subscribe(user => {
|
.then(user => {
|
||||||
this.app.user.next(user);
|
this.app.user.next(user);
|
||||||
this.router.navigate([this.redirect || '/'])
|
this.router.navigate([this.redirect || '/'])
|
||||||
},
|
})
|
||||||
error => {
|
.catch(error => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
//TODO: Replace this with an in-app dialog
|
// TODO: Replace this with an in-app dialog
|
||||||
alert("Login failed. Please verify you have the correct credentials");
|
alert("Login failed. Please verify you have the correct credentials");
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
})
|
})
|
||||||
|
|
|
@ -33,10 +33,10 @@ export class RegisterComponent implements OnInit {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.twigsService.register(this.username, this.email, this.password).subscribe(user => {
|
this.twigsService.register(this.username, this.email, this.password).then(user => {
|
||||||
console.log(user);
|
console.log(user);
|
||||||
this.router.navigate(['/'])
|
this.router.navigate(['/'])
|
||||||
}, error => {
|
}).catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert("Registration failed!")
|
alert("Registration failed!")
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
|
@ -29,8 +29,8 @@ export class UserPermission {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Permission {
|
export enum Permission {
|
||||||
READ,
|
READ = "READ",
|
||||||
WRITE,
|
WRITE = "WRITE",
|
||||||
MANAGE,
|
MANAGE = "MANAGE",
|
||||||
OWNER
|
OWNER = "OWNER"
|
||||||
}
|
}
|
|
@ -4,7 +4,16 @@
|
||||||
// Include the common styles for Angular Material. We include this here so that you only
|
// Include the common styles for Angular Material. We include this here so that you only
|
||||||
// have to load a single css file for Angular Material in your app.
|
// have to load a single css file for Angular Material in your app.
|
||||||
// Be sure that you only ever include this mixin once!
|
// Be sure that you only ever include this mixin once!
|
||||||
@include mat.core();
|
// TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles.
|
||||||
|
// The following line adds:
|
||||||
|
// 1. Default typography styles for all components
|
||||||
|
// 2. Styles for typography hierarchy classes (e.g. .mat-headline-1)
|
||||||
|
// If you specify typography styles for the components you use elsewhere, you should delete this line.
|
||||||
|
// If you don't need the default component typographies but still want the hierarchy styles,
|
||||||
|
// you can delete this line and instead use:
|
||||||
|
// `@include mat.legacy-typography-hierarchy(mat.define-legacy-typography-config());`
|
||||||
|
@include mat.all-legacy-component-typographies();
|
||||||
|
@include mat.legacy-core();
|
||||||
|
|
||||||
// Define the palettes for your theme using the Material Design palettes available in palette.scss
|
// Define the palettes for your theme using the Material Design palettes available in palette.scss
|
||||||
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
|
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
|
||||||
|
@ -23,8 +32,8 @@ $budget-dark-theme: mat.define-dark-theme($budget-app-primary, $budget-app-accen
|
||||||
// Alternatively, you can import and @include the theme mixins for each component
|
// Alternatively, you can import and @include the theme mixins for each component
|
||||||
// that you are using.
|
// that you are using.
|
||||||
|
|
||||||
@include mat.all-component-themes($budget-app-theme);
|
@include mat.all-legacy-component-themes($budget-app-theme);
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@include mat.all-component-themes($budget-dark-theme);
|
@include mat.all-legacy-component-themes($budget-dark-theme);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,6 @@ import {
|
||||||
platformBrowserDynamicTesting
|
platformBrowserDynamicTesting
|
||||||
} from '@angular/platform-browser-dynamic/testing';
|
} from '@angular/platform-browser-dynamic/testing';
|
||||||
|
|
||||||
declare const require: any;
|
|
||||||
|
|
||||||
// First, initialize the Angular testing environment.
|
// First, initialize the Angular testing environment.
|
||||||
getTestBed().initTestEnvironment(
|
getTestBed().initTestEnvironment(
|
||||||
BrowserDynamicTestingModule,
|
BrowserDynamicTestingModule,
|
||||||
|
@ -16,7 +14,3 @@ getTestBed().initTestEnvironment(
|
||||||
teardown: { destroyAfterEach: false }
|
teardown: { destroyAfterEach: false }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Then we find all the tests.
|
|
||||||
const context = require.context('./', true, /\.spec\.ts$/);
|
|
||||||
// And load the modules.
|
|
||||||
context.keys().map(context);
|
|
||||||
|
|
Loading…
Reference in a new issue