Compare commits
88 commits
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 | |||
d2f4d15bb9 | |||
cb3bce833b | |||
b6af459d44 | |||
240833e8d6 | |||
7f731a627f | |||
b752d5f708 | |||
a959736237 | |||
a3468c7781 | |||
a31e921375 | |||
27b5e80a2b | |||
037ade50c5 | |||
072d2c1ae9 | |||
ccf1acd21e | |||
9a274591ac | |||
3e70e402ea | |||
193faeb800 | |||
a4925ee783 | |||
e836d306b8 | |||
fd112cc096 | |||
c1455df969 | |||
2f4d1e2a92 | |||
9dc85c21fe | |||
9218bde745 | |||
41c7006c45 | |||
c139a3d33a | |||
d2b2c951a7 | |||
a322bd9415 | |||
2c4df90d8d | |||
ce945b8391 | |||
ae14a33616 | |||
84dae70b7f | |||
6060f282b4 | |||
9622fa47c3 | |||
92f93861e9 | |||
06850c8b8e | |||
852aa1d6c5 | |||
271bd20707 |
195 changed files with 33295 additions and 11053 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:
|
|
@ -1 +0,0 @@
|
|||
VUE_APP_API_URL=https://3000code.brawner.home
|
5
.firebaserc
Normal file
5
.firebaserc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "budget-c7da5"
|
||||
}
|
||||
}
|
22
.github/workflows/docker-image.yml
vendored
Normal file
22
.github/workflows/docker-image.yml
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
name: Publish Docker image
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}/${{ github.event.repository.name }}
|
||||
registry: docker.pkg.github.com
|
||||
tag_with_ref: true
|
20
.github/workflows/firebase-hosting-merge.yml
vendored
Normal file
20
.github/workflows/firebase-hosting-merge.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
# This file was auto-generated by the Firebase CLI
|
||||
# https://github.com/firebase/firebase-tools
|
||||
|
||||
name: Deploy to Firebase Hosting on merge
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: npm ci && npm run package
|
||||
- uses: FirebaseExtended/action-hosting-deploy@v0
|
||||
with:
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_BUDGET_C7DA5 }}'
|
||||
channelId: live
|
||||
projectId: budget-c7da5
|
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
|
59
.gitignore
vendored
59
.gitignore
vendored
|
@ -1,23 +1,46 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# misc
|
||||
/.angular/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Firebase
|
||||
.firebase/
|
||||
.angular/
|
||||
|
|
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
|
@ -4,6 +4,13 @@
|
|||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Chrome",
|
||||
"request": "launch",
|
||||
"type": "pwa-chrome",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "node",
|
||||
|
|
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,4 +1,4 @@
|
|||
FROM node:latest as builder
|
||||
FROM node:lts as builder
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm install && \
|
||||
|
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 William Brawner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
79
README.md
79
README.md
|
@ -1,38 +1,49 @@
|
|||
# Twigs
|
||||
# Twigs Web Client
|
||||
|
||||
## Vue Migration Checklist
|
||||
# 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)
|
||||
|
||||
_Could also be used as a testing checklist_
|
||||
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)
|
||||
|
||||
- [ ] Login
|
||||
- [ ] Logout
|
||||
- [ ] Register
|
||||
- [ ] Budget list
|
||||
- [ ] Create budget
|
||||
- [ ] Budget details
|
||||
- [ ]
|
||||
- [ ] Edit budget
|
||||
- [ ] Change name
|
||||
- [ ] Change description
|
||||
- [ ] Change users/permissions
|
||||
- [ ] Delete budget
|
||||
- [ ] Category list
|
||||
- [ ] Create category
|
||||
- [ ] Category details
|
||||
- [ ] Edit category
|
||||
- [ ] Change name
|
||||
- [ ] Change description
|
||||
- [ ] Change expense/income
|
||||
- [ ] Change amount
|
||||
- [ ] Delete category
|
||||
- [ ] Transaction list
|
||||
- [ ] Create transaction
|
||||
- [ ] Transaction details
|
||||
- [ ] Edit transaction
|
||||
- [ ] Change name
|
||||
- [ ] Change description
|
||||
- [ ] Change expense/income
|
||||
- [ ] Change amount
|
||||
- [ ] Change date
|
||||
- [ ] Delete transaction
|
||||
## Building
|
||||
|
||||
You'll need NodeJS and NPM, then run
|
||||
|
||||
npm run build
|
||||
|
||||
If you would like to tinker with the site and have it hot reload, then run
|
||||
|
||||
npm run start
|
||||
|
||||
The app will be available at http://localhost:4200
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Eventually the plan is to ship this web app within the JAR for the server, but for now you'll need to run them separately. Before you build the app, be sure to change the `apiUrl` value in [src/environments/environment.prod.ts](src/environments/environment.prod.ts). Then you can run the following command to get an optimized version of the build for production deployments
|
||||
|
||||
npm run package
|
||||
|
||||
This will output the app in a folder called `dist/twigs`, which you can then serve directly with Apache or Nginx or any static file server.
|
||||
|
||||
## License
|
||||
|
||||
```
|
||||
Copyright (c) 2021 William Brawner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
|
144
angular.json
Normal file
144
angular.json
Normal file
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"twigs": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/twigs",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/browserconfig.xml",
|
||||
"src/favicon-16x16.png",
|
||||
"src/favicon-32x32.png",
|
||||
"src/favicon-96x96.png",
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.json"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"serviceWorker": true
|
||||
},
|
||||
"codeserver": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.codeserver.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "twigs:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "twigs:build:production"
|
||||
},
|
||||
"codeserver": {
|
||||
"buildTarget": "twigs:build:codeserver"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "twigs:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "src/tsconfig.spec.json",
|
||||
"karmaConfig": "src/karma.conf.js",
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": [],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"twigs-e2e": {
|
||||
"root": "e2e/",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "twigs:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "twigs:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "b8304464-255e-47bb-976a-7ed81af63238"
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
28
e2e/protractor.conf.js
Normal file
28
e2e/protractor.conf.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
'browserName': 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.e2e.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
14
e2e/src/app.e2e-spec.ts
Normal file
14
e2e/src/app.e2e-spec.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { AppPage } from './app.po';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getParagraphText()).toEqual('Welcome to budget!');
|
||||
});
|
||||
});
|
11
e2e/src/app.po.ts
Normal file
11
e2e/src/app.po.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get('/');
|
||||
}
|
||||
|
||||
getParagraphText() {
|
||||
return element(by.css('app-root h1')).getText();
|
||||
}
|
||||
}
|
13
e2e/tsconfig.e2e.json
Normal file
13
e2e/tsconfig.e2e.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
16
firebase.json
Normal file
16
firebase.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"hosting": {
|
||||
"public": "dist/twigs",
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
26
ngsw-config.json
Normal file
26
ngsw-config.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.html",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
}, {
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
37673
package-lock.json
generated
37673
package-lock.json
generated
File diff suppressed because it is too large
Load diff
94
package.json
94
package.json
|
@ -1,48 +1,58 @@
|
|||
{
|
||||
"name": "twigs-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"name": "budget",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"ng": "ng",
|
||||
"start": "ng serve --configuration=production --host '0.0.0.0'",
|
||||
"code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000",
|
||||
"build": "ng build",
|
||||
"package": "ng build --configuration=production --service-worker",
|
||||
"publish": "ng build --configuration=production --service-worker && firebase deploy",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e",
|
||||
"update": "ncu -u"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"core-js": "^3.6.5",
|
||||
"register-service-worker": "^1.7.1",
|
||||
"vue": "^2.6.11",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuex": "^3.4.0"
|
||||
"@angular/animations": "^17.2.3",
|
||||
"@angular/cdk": "^17.2.1",
|
||||
"@angular/common": "^17.2.3",
|
||||
"@angular/compiler": "^17.2.3",
|
||||
"@angular/core": "^17.2.3",
|
||||
"@angular/forms": "^17.2.3",
|
||||
"@angular/material": "^16.2.0",
|
||||
"@angular/platform-browser": "^17.2.3",
|
||||
"@angular/platform-browser-dynamic": "^17.2.3",
|
||||
"@angular/router": "^17.2.3",
|
||||
"@angular/service-worker": "^17.2.3",
|
||||
"chart.js": "^3.7.0",
|
||||
"core-js": "^3.20.3",
|
||||
"ng2-charts": "^3.0.8",
|
||||
"rxjs": "^7.5.2",
|
||||
"tslib": "^2.3.1",
|
||||
"zone.js": "~0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-pwa": "^4.5.7",
|
||||
"@vue/cli-plugin-router": "^4.5.7",
|
||||
"@vue/cli-plugin-vuex": "^4.5.7",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
"@angular-devkit/build-angular": "^17.2.2",
|
||||
"@angular/cli": "^17.2.2",
|
||||
"@angular/compiler-cli": "^17.2.3",
|
||||
"@angular/language-service": "^17.2.3",
|
||||
"@types/jasmine": "~3.10.3",
|
||||
"@types/jasminewd2": "^2.0.10",
|
||||
"@types/node": "^17.0.10",
|
||||
"eslint": "^8.7.0",
|
||||
"jasmine-core": "~4.0.0",
|
||||
"jasmine-spec-reporter": "~7.0.0",
|
||||
"karma": "~6.3.11",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "~3.0.3",
|
||||
"karma-jasmine": "~4.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.7.0",
|
||||
"npm-check-updates": "^15.0.1",
|
||||
"protractor": "^7.0.0",
|
||||
"ts-node": "~10.4.0",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<link rel="stylesheet" href="<%= BASE_URL %>style.css">
|
||||
<title>Twigs</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but Twigs doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow:
|
|
@ -1,37 +0,0 @@
|
|||
:root {
|
||||
--good-color: green;
|
||||
--warn-color: yellow;
|
||||
--danger-color: red;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: var(--good-color);
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warn-color);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger-color);
|
||||
}
|
11
src/.browserslistrc
Normal file
11
src/.browserslistrc
Normal file
|
@ -0,0 +1,11 @@
|
|||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
#
|
||||
# For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed
|
||||
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
not IE 9-11
|
21
src/App.vue
21
src/App.vue
|
@ -1,21 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
export default {
|
||||
components: {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.app-twigs {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.app-twigs > div {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
13
src/app/app-routing.module.spec.ts
Normal file
13
src/app/app-routing.module.spec.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
describe('AppRoutingModule', () => {
|
||||
let appRoutingModule: AppRoutingModule;
|
||||
|
||||
beforeEach(() => {
|
||||
appRoutingModule = new AppRoutingModule();
|
||||
});
|
||||
|
||||
it('should create an instance', () => {
|
||||
expect(appRoutingModule).toBeTruthy();
|
||||
});
|
||||
});
|
43
src/app/app-routing.module.ts
Normal file
43
src/app/app-routing.module.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { TransactionsComponent } from './transactions/transactions.component';
|
||||
import { TransactionDetailsComponent } from './transactions/transaction-details/transaction-details.component';
|
||||
import { NewTransactionComponent } from './transactions/new-transaction/new-transaction.component';
|
||||
import { CategoriesComponent } from './categories/categories.component';
|
||||
import { CategoryDetailsComponent } from './categories/category-details/category-details.component';
|
||||
import { NewCategoryComponent } from './categories/new-category/new-category.component';
|
||||
import { LoginComponent } from './users/login/login.component';
|
||||
import { RegisterComponent } from './users/register/register.component';
|
||||
import { BudgetsComponent } from './budgets/budget.component';
|
||||
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
|
||||
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
|
||||
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
|
||||
import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: BudgetsComponent },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
{ path: 'budgets', component: BudgetsComponent },
|
||||
{ path: 'budgets/new', component: NewBudgetComponent },
|
||||
{ path: 'budgets/:id', component: BudgetDetailsComponent },
|
||||
{ path: 'budgets/:id/edit', component: EditBudgetComponent },
|
||||
{ path: 'transactions', component: TransactionsComponent },
|
||||
{ path: 'transactions/new', component: NewTransactionComponent },
|
||||
{ path: 'transactions/:id', component: TransactionDetailsComponent },
|
||||
{ path: 'categories', component: CategoriesComponent },
|
||||
{ path: 'categories/new', component: NewCategoryComponent },
|
||||
{ path: 'categories/:id', component: CategoryDetailsComponent },
|
||||
{ path: 'categories/:id/edit', component: EditCategoryComponent },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {})
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
],
|
||||
declarations: []
|
||||
})
|
||||
export class AppRoutingModule { }
|
15
src/app/app.component.css
Normal file
15
src/app/app.component.css
Normal file
|
@ -0,0 +1,15 @@
|
|||
mat-toolbar {
|
||||
background-color: #fafafa;
|
||||
box-shadow: none;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mat-toolbar {
|
||||
background-color: #303030;
|
||||
}
|
||||
}
|
35
src/app/app.component.html
Normal file
35
src/app/app.component.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
<p *ngIf="!online" class="error-offline">
|
||||
You appear to be offline. Twigs unfortunately doesn't currently support offline use at the moment though it may be implemented in a future release!
|
||||
</p>
|
||||
<mat-sidenav-container *ngIf="online" class="sidenav-container">
|
||||
<mat-sidenav #sidenav mode="over" closed>
|
||||
<mat-nav-list (click)="sidenav.close()" *ngIf="loggedIn">
|
||||
<a mat-list-item routerLink="">{{ getUsername() }}</a>
|
||||
<a mat-list-item routerLink="/budgets">Budgets</a>
|
||||
<a mat-list-item (click)="logout()">Logout</a>
|
||||
</mat-nav-list>
|
||||
<mat-nav-list (click)="sidenav.close()" *ngIf="!loggedIn">
|
||||
<a mat-list-item routerLink="/login">Login</a>
|
||||
<a mat-list-item routerLink="/register">Register</a>
|
||||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
<mat-toolbar>
|
||||
<span>
|
||||
<a mat-icon-button *ngIf="backEnabled" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</a>
|
||||
<a mat-icon-button *ngIf="!backEnabled" (click)="sidenav.open()">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="action-item">
|
||||
<a mat-button *ngIf="actionable" (click)="actionable.doAction()">{{ actionable.getActionLabel() }}</a>
|
||||
</span>
|
||||
</mat-toolbar>
|
||||
<router-outlet></router-outlet>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
27
src/app/app.component.spec.ts
Normal file
27
src/app/app.component.spec.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
it('should create the app', waitForAsync(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
}));
|
||||
it(`should have as title 'budget'`, waitForAsync(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app.title).toEqual('budget');
|
||||
}));
|
||||
it('should render title in a h1 tag', waitForAsync(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.debugElement.nativeElement;
|
||||
expect(compiled.querySelector('h1').textContent).toContain('Welcome to budget!');
|
||||
}));
|
||||
});
|
134
src/app/app.component.ts
Normal file
134
src/app/app.component.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { Component, Inject, ApplicationRef, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||
import { DOCUMENT, Location } from '@angular/common';
|
||||
import { User } from './users/user';
|
||||
import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service';
|
||||
import { SwUpdate } from '@angular/service-worker';
|
||||
import { first, filter, map } from 'rxjs/operators';
|
||||
import { interval, concat, BehaviorSubject } from 'rxjs';
|
||||
import { Router, ActivationEnd, ActivatedRoute } from '@angular/router';
|
||||
import { Actionable, isActionable } from './shared/actionable';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
public title = 'Twigs';
|
||||
public backEnabled = false;
|
||||
public user = new BehaviorSubject<User>(null);
|
||||
public online = window.navigator.onLine;
|
||||
public currentVersion = '';
|
||||
public actionable: Actionable;
|
||||
public loggedIn = false;
|
||||
|
||||
constructor(
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
private location: Location,
|
||||
private router: Router,
|
||||
private appRef: ApplicationRef,
|
||||
private updates: SwUpdate,
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private storage: Storage,
|
||||
@Inject(DOCUMENT) private document: Document
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
const unauthenticatedRoutes = [
|
||||
'',
|
||||
'/',
|
||||
'/login',
|
||||
'/register'
|
||||
]
|
||||
let auth = this.storage.getItem('Authorization');
|
||||
let userId = this.storage.getItem('userId');
|
||||
let savedUser = JSON.parse(this.storage.getItem('user')) as User;
|
||||
if (auth && auth.length == 255 && userId) {
|
||||
if (savedUser) {
|
||||
this.user.next(savedUser);
|
||||
}
|
||||
this.twigsService.getProfile(userId).then(fetchedUser => {
|
||||
this.storage.setItem('user', JSON.stringify(fetchedUser));
|
||||
this.user.next(fetchedUser);
|
||||
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
|
||||
//TODO: Save last opened budget and redirect to there instead of the main list
|
||||
this.router.navigateByUrl("/budgets");
|
||||
}
|
||||
});
|
||||
} else if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
|
||||
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
|
||||
}
|
||||
|
||||
this.updates.versionUpdates.subscribe(
|
||||
event => {
|
||||
if (event.type == "VERSION_READY") {
|
||||
console.log('current version is', event.currentVersion);
|
||||
console.log('available version is', event.latestVersion);
|
||||
// TODO: Prompt user to click something to update
|
||||
this.updates.activateUpdate();
|
||||
}
|
||||
},
|
||||
err => {
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
|
||||
const everySixHours$ = interval(6 * 60 * 60 * 1000);
|
||||
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
|
||||
everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
|
||||
this.user.subscribe(
|
||||
user => {
|
||||
if (user) {
|
||||
this.loggedIn = true;
|
||||
} else {
|
||||
this.loggedIn = false;
|
||||
}
|
||||
}
|
||||
)
|
||||
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.handleDarkModeChanges(darkMode);
|
||||
darkMode.addEventListener('change', (e => this.handleDarkModeChanges(e)))
|
||||
}
|
||||
|
||||
getUsername(): String {
|
||||
return this.user.value.username;
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.twigsService.logout().then(_ => {
|
||||
this.location.go('/');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
setActionable(actionable: Actionable): void {
|
||||
this.actionable = actionable;
|
||||
this.changeDetector.detectChanges();
|
||||
}
|
||||
|
||||
setBackEnabled(enabled: boolean): void {
|
||||
this.backEnabled = enabled;
|
||||
this.changeDetector.detectChanges();
|
||||
}
|
||||
|
||||
setTitle(title: string) {
|
||||
this.title = title;
|
||||
this.changeDetector.detectChanges();
|
||||
}
|
||||
|
||||
handleDarkModeChanges(darkMode: any) {
|
||||
const themeColor = this.document.getElementsByName('theme-color')[0] as HTMLMetaElement;
|
||||
let themeColorValue: string;
|
||||
if (darkMode.matches) {
|
||||
themeColorValue = '#333333';
|
||||
} else {
|
||||
themeColorValue = '#F1F1F1';
|
||||
}
|
||||
themeColor.content = themeColorValue;
|
||||
}
|
||||
}
|
108
src/app/app.module.ts
Normal file
108
src/app/app.module.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
|
||||
import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar';
|
||||
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
|
||||
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { TransactionsComponent } from './transactions/transactions.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { BudgetsComponent } from './budgets/budget.component';
|
||||
import { TransactionDetailsComponent } from './transactions/transaction-details/transaction-details.component';
|
||||
import { NewTransactionComponent } from './transactions/new-transaction/new-transaction.component';
|
||||
import { AddEditTransactionComponent } from './transactions/add-edit-transaction/add-edit-transaction.component';
|
||||
import { CategoriesComponent } from './categories/categories.component';
|
||||
import { CategoryDetailsComponent } from './categories/category-details/category-details.component';
|
||||
import { CategoryFormComponent } from './categories/category-form/category-form.component';
|
||||
import { NewCategoryComponent } from './categories/new-category/new-category.component';
|
||||
import { CategoryListComponent } from './categories/category-list/category-list.component';
|
||||
import { LoginComponent } from './users/login/login.component';
|
||||
import { RegisterComponent } from './users/register/register.component';
|
||||
import { AddEditBudgetComponent } from './budgets/add-edit-budget/add-edit-budget.component';
|
||||
import { EditProfileComponent } from './users/edit-profile/edit-profile.component';
|
||||
import { UserComponent } from './users/user.component';
|
||||
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
|
||||
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';
|
||||
import { NgChartsModule } from 'ng2-charts';
|
||||
import { TWIGS_SERVICE } from './shared/twigs.service';
|
||||
import { AuthInterceptor } from './shared/auth.interceptor';
|
||||
import { TwigsHttpService } from './shared/twigs.http.service';
|
||||
import { TwigsLocalService } from './shared/twigs.local.service';
|
||||
import { TransactionListComponent } from './transactions/transaction-list/transaction-list.component';
|
||||
import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
|
||||
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
TransactionsComponent,
|
||||
TransactionDetailsComponent,
|
||||
NewTransactionComponent,
|
||||
AddEditTransactionComponent,
|
||||
CategoriesComponent,
|
||||
CategoryDetailsComponent,
|
||||
CategoryFormComponent,
|
||||
NewCategoryComponent,
|
||||
CategoryListComponent,
|
||||
LoginComponent,
|
||||
RegisterComponent,
|
||||
AddEditBudgetComponent,
|
||||
EditProfileComponent,
|
||||
UserComponent,
|
||||
NewBudgetComponent,
|
||||
BudgetDetailsComponent,
|
||||
BudgetsComponent,
|
||||
CategoryBreakdownComponent,
|
||||
TransactionListComponent,
|
||||
EditCategoryComponent,
|
||||
EditBudgetComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatDatepickerModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatRadioModule,
|
||||
MatProgressBarModule,
|
||||
MatSelectModule,
|
||||
MatToolbarModule,
|
||||
MatSidenavModule,
|
||||
MatProgressSpinnerModule,
|
||||
AppRoutingModule,
|
||||
FormsModule,
|
||||
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
|
||||
HttpClientModule,
|
||||
NgChartsModule,
|
||||
MatCheckboxModule,
|
||||
MatCardModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||
{ provide: TWIGS_SERVICE, useClass: TwigsHttpService },
|
||||
{ provide: Storage, useValue: window.localStorage },
|
||||
// { provide: TWIGS_SERVICE, useClass: TwigsLocalService },
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
|
@ -0,0 +1,14 @@
|
|||
<mat-progress-spinner *ngIf="isLoading" mode="indeterminate" diameter="50"></mat-progress-spinner>
|
||||
<div *ngIf="!isLoading && !budget">
|
||||
<p>Select a budget from the list to view details about it or edit it.</p>
|
||||
</div>
|
||||
<div *ngIf="!isLoading && budget" class="form budget-form">
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="budget.name" placeholder="Name" required autocapitalize="words">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<textarea matInput [(ngModel)]="budget.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="accent" (click)="save()">Save</button>
|
||||
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
|
||||
</div>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { AddEditBudgetComponent } from './add-edit-budget.component';
|
||||
|
||||
describe('AddEditBudgetComponent', () => {
|
||||
let component: AddEditBudgetComponent;
|
||||
let fixture: ComponentFixture<AddEditBudgetComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ AddEditBudgetComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(AddEditBudgetComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
70
src/app/budgets/add-edit-budget/add-edit-budget.component.ts
Normal file
70
src/app/budgets/add-edit-budget/add-edit-budget.component.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { Component, OnInit, Input, Inject, OnDestroy } from '@angular/core';
|
||||
import { Budget } from '../budget';
|
||||
import { AppComponent } from 'src/app/app.component';
|
||||
import { User, UserPermission, Permission } from 'src/app/users/user';
|
||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-edit-budget',
|
||||
templateUrl: './add-edit-budget.component.html',
|
||||
styleUrls: ['./add-edit-budget.component.css']
|
||||
})
|
||||
export class AddEditBudgetComponent {
|
||||
@Input() title: string;
|
||||
@Input() budget: Budget;
|
||||
@Input() create: boolean;
|
||||
public users: UserPermission[];
|
||||
public searchedUsers: User[] = [];
|
||||
public isLoading = false;
|
||||
|
||||
constructor(
|
||||
private app: AppComponent,
|
||||
private router: Router,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) {
|
||||
this.app.setTitle(this.title)
|
||||
this.app.setBackEnabled(true);
|
||||
this.users = [new UserPermission(this.app.user.value.id, Permission.OWNER)];
|
||||
}
|
||||
|
||||
save(): void {
|
||||
let promise: Promise<Budget>;
|
||||
this.isLoading = true;
|
||||
if (this.create) {
|
||||
// This is a new budget, save it
|
||||
promise = this.twigsService.createBudget(
|
||||
this.budget.id,
|
||||
this.budget.name,
|
||||
this.budget.description,
|
||||
this.users
|
||||
);
|
||||
} else {
|
||||
// This is an existing budget, update it
|
||||
promise = this.twigsService.updateBudget(this.budget.id, this.budget);
|
||||
}
|
||||
// TODO: Check if it was actually successful or not
|
||||
promise.then(_ => {
|
||||
this.app.goBack();
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
this.isLoading = true;
|
||||
this.twigsService.deleteBudget(this.budget.id)
|
||||
.then(() => {
|
||||
this.router.navigateByUrl("/budgets");
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement a search box with suggestions to add users
|
||||
searchUsers(username: string) {
|
||||
this.twigsService.getUsersByUsername(username).then(users => {
|
||||
this.searchedUsers = users;
|
||||
});
|
||||
}
|
||||
|
||||
clearUserSearch() {
|
||||
this.searchedUsers = [];
|
||||
}
|
||||
}
|
111
src/app/budgets/budget-details/budget-details.component.css
Normal file
111
src/app/budgets/budget-details/budget-details.component.css
Normal file
|
@ -0,0 +1,111 @@
|
|||
.dashboard {
|
||||
color: #333333;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.dashboard>mat-card {
|
||||
background: #FFFFFF;
|
||||
display: inline-block;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.dashboard .dashboard-primary {
|
||||
padding: 5em 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-primary>* {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard div h2,
|
||||
.dashboard div h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard p,
|
||||
.dashboard a {
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-primary div {
|
||||
bottom: 0.5em;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
left: 0.5em;
|
||||
right: 0.5em;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dashboard .no-categories {
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard .no-categories a {
|
||||
border-color: #333333;
|
||||
display: inline-block;
|
||||
border: 1px dashed;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.dashboard .no-categories p {
|
||||
line-height: normal;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
a.view-all {
|
||||
position: absolute;
|
||||
right: 0.5em;
|
||||
top: 0.5em;
|
||||
}
|
||||
|
||||
@media (min-width: 1160px) {
|
||||
mat-card {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.category-info {
|
||||
height: 313px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 610px) {
|
||||
.dashboard {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard>mat-card {
|
||||
margin: 1em auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dashboard {
|
||||
color: #F1F1F1;
|
||||
}
|
||||
|
||||
.dashboard>mat-card {
|
||||
background: #212121;
|
||||
}
|
||||
|
||||
.dashboard p,
|
||||
.dashboard a {
|
||||
color: #F1F1F1;
|
||||
}
|
||||
|
||||
.dashboard .no-categories a {
|
||||
border-color: #F1F1F1;
|
||||
}
|
||||
}
|
45
src/app/budgets/budget-details/budget-details.component.html
Normal file
45
src/app/budgets/budget-details/budget-details.component.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<div class="dashboard">
|
||||
<mat-card class="dashboard-primary" [hidden]="!budget">
|
||||
<h2 class="balance">
|
||||
Current Balance: <br />
|
||||
<span
|
||||
[ngClass]="{'income': budgetBalance > 0, 'expense': budgetBalance < 0}">{{ budgetBalance / 100 | currency }}</span>
|
||||
</h2>
|
||||
<app-category-breakdown [barChartLabels]="barChartLabels" [barChartData]="barChartData">
|
||||
</app-category-breakdown>
|
||||
<div class="transaction-navigation">
|
||||
<a mat-button routerLink="/transactions" [queryParams]="{budgetIds: budget.id}" *ngIf="budget">View Transactions</a>
|
||||
</div>
|
||||
</mat-card>
|
||||
<mat-card class="dashboard-categories" [hidden]="!budget">
|
||||
<h3 class="categories">Income</h3>
|
||||
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" class="view-all" *ngIf="budget">Add Category</a>
|
||||
<div class="no-categories" *ngIf="!income || income.length === 0">
|
||||
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
|
||||
<mat-icon>add</mat-icon>
|
||||
<p>Add categories to gain more insights into your income.</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="category-info" *ngIf="income && income.length > 0">
|
||||
<app-category-list [budgetId]="budget.id" [categories]="income" [categoryBalances]="categoryBalances">
|
||||
</app-category-list>
|
||||
</div>
|
||||
</mat-card>
|
||||
<mat-card class="dashboard-categories" [hidden]="!budget">
|
||||
<h3 class="categories">Expenses</h3>
|
||||
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" class="view-all" *ngIf="budget">Add Category</a>
|
||||
<div class="no-categories" *ngIf="!expenses || expenses.length === 0">
|
||||
<a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" *ngIf="budget">
|
||||
<mat-icon>add</mat-icon>
|
||||
<p>Add categories to gain more insights into your expenses.</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="category-info" *ngIf="expenses && expenses.length > 0">
|
||||
<app-category-list [budgetId]="budget.id" [categories]="expenses" [categoryBalances]="categoryBalances">
|
||||
</app-category-list>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
|
||||
<mat-icon aria-label="Add">add</mat-icon>
|
||||
</a>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { BudgetDetailsComponent } from './budget-details.component';
|
||||
|
||||
describe('BudgetDetailsComponent', () => {
|
||||
let component: BudgetDetailsComponent;
|
||||
let fixture: ComponentFixture<BudgetDetailsComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ BudgetDetailsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BudgetDetailsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
173
src/app/budgets/budget-details/budget-details.component.ts
Normal file
173
src/app/budgets/budget-details/budget-details.component.ts
Normal file
|
@ -0,0 +1,173 @@
|
|||
import { Component, OnInit, Inject, OnDestroy } from '@angular/core';
|
||||
import { Budget } from '../budget';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AppComponent } from 'src/app/app.component';
|
||||
import { Transaction } from 'src/app/transactions/transaction';
|
||||
import { Category } from 'src/app/categories/category';
|
||||
import { ChartDataset } from 'chart.js';
|
||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||
import { Actionable } from '../../shared/actionable';
|
||||
|
||||
@Component({
|
||||
selector: 'app-budget-details',
|
||||
templateUrl: './budget-details.component.html',
|
||||
styleUrls: ['./budget-details.component.css']
|
||||
})
|
||||
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||
budget: Budget;
|
||||
public budgetBalance: number;
|
||||
public transactions: Transaction[];
|
||||
public expenses: Category[] = [];
|
||||
public income: Category[] = [];
|
||||
categoryBalances: Map<string, number>;
|
||||
expectedIncome = 0;
|
||||
actualIncome = 0;
|
||||
expectedExpenses = 0;
|
||||
actualExpenses = 0;
|
||||
barChartLabels: string[] = ['Income', 'Expenses'];
|
||||
barChartData: ChartDataset[] = [
|
||||
{ data: [0, 0], label: 'Expected' },
|
||||
{ data: [0, 0], label: 'Actual' },
|
||||
];
|
||||
from: Date
|
||||
to: Date
|
||||
|
||||
constructor(
|
||||
private app: AppComponent,
|
||||
private route: ActivatedRoute,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
private router: Router,
|
||||
) {
|
||||
let fromStr = this.route.snapshot.queryParamMap.get('from');
|
||||
if (fromStr) {
|
||||
let fromDate = new Date(fromStr);
|
||||
if (!isNaN(fromDate.getTime())) {
|
||||
this.from = fromDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.from) {
|
||||
let date = new Date();
|
||||
date.setHours(0);
|
||||
date.setMinutes(0);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
date.setDate(1);
|
||||
this.from = date;
|
||||
}
|
||||
|
||||
let toStr = this.route.snapshot.queryParamMap.get('to');
|
||||
if (toStr) {
|
||||
let toDate = new Date(toStr);
|
||||
if (!isNaN(toDate.getTime())) {
|
||||
this.to = toDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.getBudget();
|
||||
this.app.setBackEnabled(false);
|
||||
this.app.setActionable(this)
|
||||
this.categoryBalances = new Map();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.app.setActionable(null)
|
||||
}
|
||||
|
||||
getBudget() {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
this.twigsService.getBudget(id)
|
||||
.then(budget => {
|
||||
this.app.setTitle(budget.name)
|
||||
this.budget = budget;
|
||||
this.getBalance();
|
||||
this.getTransactions();
|
||||
this.getCategories();
|
||||
});
|
||||
}
|
||||
|
||||
updateBarChart() {
|
||||
const color = [0, 188, 212];
|
||||
this.barChartData = [
|
||||
{
|
||||
data: [this.expectedIncome / 100, this.expectedExpenses / 100],
|
||||
label: 'Expected',
|
||||
backgroundColor: 'rgba(241, 241, 241, 0.8)',
|
||||
borderColor: 'rgba(241, 241, 241, 0.9)',
|
||||
hoverBackgroundColor: 'rgba(241, 241, 241, 1)',
|
||||
hoverBorderColor: 'rgba(241, 241, 241, 1)',
|
||||
},
|
||||
{
|
||||
data: [this.actualIncome / 100, this.actualExpenses / 100],
|
||||
label: 'Actual',
|
||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.8)`,
|
||||
borderColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.9)`,
|
||||
hoverBackgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`,
|
||||
hoverBorderColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getBalance(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
this.twigsService.getBudgetBalance(id, this.from, this.to)
|
||||
.then(balance => {
|
||||
this.budgetBalance = balance;
|
||||
});
|
||||
}
|
||||
|
||||
getTransactions(): void {
|
||||
let date = new Date();
|
||||
date.setHours(0);
|
||||
date.setMinutes(0);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
date.setDate(1);
|
||||
this.twigsService.getTransactions(this.budget.id, null, 5, date)
|
||||
.then(transactions => this.transactions = <Transaction[]>transactions);
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
const categories = await this.twigsService.getCategories(this.budget.id)
|
||||
const categoryBalances = new Map<string, number>();
|
||||
let categoryBalancesCount = 0;
|
||||
for (const category of categories) {
|
||||
if (category.expense) {
|
||||
this.expenses.push(category);
|
||||
this.expectedExpenses += category.amount;
|
||||
} else {
|
||||
this.income.push(category);
|
||||
this.expectedIncome += category.amount;
|
||||
}
|
||||
try {
|
||||
const balance = await this.twigsService.getCategoryBalance(category.id, this.from, this.to)
|
||||
console.log(balance);
|
||||
if (category.expense) {
|
||||
this.actualExpenses += balance * -1;
|
||||
} else {
|
||||
this.actualIncome += balance;
|
||||
}
|
||||
categoryBalances.set(category.id, balance);
|
||||
if (categoryBalancesCount === categories.length - 1) {
|
||||
// This weird workaround is to force the OnChanges callback to be fired.
|
||||
// Angular needs the reference to the object to change in order for it to
|
||||
// work.
|
||||
this.categoryBalances = categoryBalances;
|
||||
this.updateBarChart();
|
||||
}
|
||||
} finally {
|
||||
categoryBalancesCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doAction(): void {
|
||||
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
||||
}
|
||||
|
||||
getActionLabel(): string {
|
||||
return "Edit";
|
||||
}
|
||||
}
|
27
src/app/budgets/budget.component.css
Normal file
27
src/app/budgets/budget.component.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
.dashboard {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-button-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
justify-content: space-between;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.auth-button-container > a {
|
||||
flex-grow: 1;
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
@media all and (max-width: 400px) {
|
||||
.auth-button-container {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
28
src/app/budgets/budget.component.html
Normal file
28
src/app/budgets/budget.component.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<mat-progress-spinner *ngIf="loading" diameter="50" mode="indeterminate"></mat-progress-spinner>
|
||||
<div class="dashboard" *ngIf="!loading && !loggedIn">
|
||||
<h2 class="log-in">Welcome to Twigs!</h2>
|
||||
<p>To begin tracking your finances, login or create an account!</p>
|
||||
<div class="auth-button-container">
|
||||
<a routerLink="/register" mat-stroked-button color="accent">Register</a>
|
||||
<a routerLink="/login" mat-raised-button color="accent">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
<mat-nav-list class="budgets" *ngIf="!loading && loggedIn">
|
||||
<a mat-list-item *ngFor="let budget of budgets" routerLink="/budgets/{{ budget.id }}">
|
||||
<p matLine class="budget-list-title">
|
||||
{{ budget.name }}
|
||||
</p>
|
||||
<p matLine class="budget-list-description">
|
||||
{{ budget.description }}
|
||||
</p>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
<div class="no-budgets" *ngIf="!loading && loggedIn && (!budgets || budgets.length === 0)">
|
||||
<a mat-button routerLink="/budgets/new">
|
||||
<mat-icon>add</mat-icon>
|
||||
<p>Add budgets to begin tracking your finances.</p>
|
||||
</a>
|
||||
</div>
|
||||
<a mat-fab routerLink="/budgets/new" *ngIf="!loading && loggedIn">
|
||||
<mat-icon aria-label="Add">add</mat-icon>
|
||||
</a>
|
25
src/app/budgets/budget.component.spec.ts
Normal file
25
src/app/budgets/budget.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { BudgetsComponent } from './budget.component';
|
||||
|
||||
describe('BudgetsComponent', () => {
|
||||
let component: BudgetsComponent;
|
||||
let fixture: ComponentFixture<BudgetsComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ BudgetsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BudgetsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
52
src/app/budgets/budget.component.ts
Normal file
52
src/app/budgets/budget.component.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { Component, OnInit, Input, Inject, ChangeDetectorRef } from '@angular/core';
|
||||
import { AppComponent } from '../app.component';
|
||||
import { Budget } from './budget';
|
||||
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-budgets',
|
||||
templateUrl: './budget.component.html',
|
||||
styleUrls: ['./budget.component.css']
|
||||
})
|
||||
export class BudgetsComponent implements OnInit {
|
||||
|
||||
public budgets: Budget[];
|
||||
public loading = true;
|
||||
public loggedIn = false;
|
||||
|
||||
constructor(
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.app.setBackEnabled(false);
|
||||
this.app.user.subscribe(
|
||||
user => {
|
||||
if (!user) {
|
||||
this.loading = false;
|
||||
this.loggedIn = false;
|
||||
this.app.setTitle('Welcome')
|
||||
return;
|
||||
}
|
||||
this.app.setTitle('Budgets')
|
||||
this.loggedIn = true;
|
||||
this.loading = true;
|
||||
this.twigsService.getBudgets()
|
||||
.then(
|
||||
budgets => {
|
||||
console.log(budgets)
|
||||
this.budgets = budgets;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
error => {
|
||||
this.loading = false;
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
9
src/app/budgets/budget.ts
Normal file
9
src/app/budgets/budget.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { UserPermission } from '../users/user';
|
||||
import { randomId } from '../shared/utils';
|
||||
|
||||
export class Budget {
|
||||
id: string = randomId();
|
||||
name: string;
|
||||
description: string;
|
||||
users: UserPermission[];
|
||||
}
|
1
src/app/budgets/edit-budget/edit-budget.component.html
Normal file
1
src/app/budgets/edit-budget/edit-budget.component.html
Normal file
|
@ -0,0 +1 @@
|
|||
<app-add-edit-budget [title]="'Edit Budget'" [budget]="budget" [create]="false"></app-add-edit-budget>
|
25
src/app/budgets/edit-budget/edit-budget.component.spec.ts
Normal file
25
src/app/budgets/edit-budget/edit-budget.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { EditBudgetComponent } from './edit-budget.component';
|
||||
|
||||
describe('EditBudgetComponent', () => {
|
||||
let component: EditBudgetComponent;
|
||||
let fixture: ComponentFixture<EditBudgetComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ EditBudgetComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditBudgetComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
27
src/app/budgets/edit-budget/edit-budget.component.ts
Normal file
27
src/app/budgets/edit-budget/edit-budget.component.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TwigsService, TWIGS_SERVICE } from '../../shared/twigs.service';
|
||||
import { Budget } from '../budget';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-budget',
|
||||
templateUrl: './edit-budget.component.html',
|
||||
styleUrls: ['./edit-budget.component.css']
|
||||
})
|
||||
export class EditBudgetComponent implements OnInit {
|
||||
|
||||
budget: Budget;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
this.twigsService.getBudget(id)
|
||||
.then(budget => {
|
||||
this.budget = budget;
|
||||
});
|
||||
}
|
||||
}
|
0
src/app/budgets/new-budget/new-budget.component.css
Normal file
0
src/app/budgets/new-budget/new-budget.component.css
Normal file
1
src/app/budgets/new-budget/new-budget.component.html
Normal file
1
src/app/budgets/new-budget/new-budget.component.html
Normal file
|
@ -0,0 +1 @@
|
|||
<app-add-edit-budget [title]="'Add Budget'" [budget]="budget" [create]="true"></app-add-edit-budget>
|
25
src/app/budgets/new-budget/new-budget.component.spec.ts
Normal file
25
src/app/budgets/new-budget/new-budget.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { NewBudgetComponent } from './new-budget.component';
|
||||
|
||||
describe('NewBudgetComponent', () => {
|
||||
let component: NewBudgetComponent;
|
||||
let fixture: ComponentFixture<NewBudgetComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ NewBudgetComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NewBudgetComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
20
src/app/budgets/new-budget/new-budget.component.ts
Normal file
20
src/app/budgets/new-budget/new-budget.component.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { Budget } from '../budget';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new-budget',
|
||||
templateUrl: './new-budget.component.html',
|
||||
styleUrls: ['./new-budget.component.css']
|
||||
})
|
||||
export class NewBudgetComponent implements OnInit {
|
||||
|
||||
public budget: Budget;
|
||||
|
||||
constructor() {
|
||||
this.budget = new Budget();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
0
src/app/categories/categories.component.css
Normal file
0
src/app/categories/categories.component.css
Normal file
4
src/app/categories/categories.component.html
Normal file
4
src/app/categories/categories.component.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<app-category-list [budgetId]="budgetId" [categories]="categories" [categoryBalances]="categoryBalances"></app-category-list>
|
||||
<a mat-fab routerLink="/budgets/{{ budgetId }}/categories/new">
|
||||
<mat-icon aria-label="Add">add</mat-icon>
|
||||
</a>
|
25
src/app/categories/categories.component.spec.ts
Normal file
25
src/app/categories/categories.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { CategoriesComponent } from './categories.component';
|
||||
|
||||
describe('CategoriesComponent', () => {
|
||||
let component: CategoriesComponent;
|
||||
let fixture: ComponentFixture<CategoriesComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CategoriesComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategoriesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
61
src/app/categories/categories.component.ts
Normal file
61
src/app/categories/categories.component.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { Category } from './category';
|
||||
import { AppComponent } from '../app.component';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
|
||||
import { Transaction } from '../transactions/transaction';
|
||||
|
||||
@Component({
|
||||
selector: 'app-categories',
|
||||
templateUrl: './categories.component.html',
|
||||
styleUrls: ['./categories.component.css']
|
||||
})
|
||||
export class CategoriesComponent implements OnInit {
|
||||
|
||||
budgetId: string;
|
||||
public categories: Category[];
|
||||
public categoryBalances: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.budgetId = this.route.snapshot.paramMap.get('budgetId');
|
||||
this.app.setTitle('Categories')
|
||||
this.app.setBackEnabled(true);
|
||||
this.getCategories();
|
||||
this.categoryBalances = new Map();
|
||||
}
|
||||
|
||||
getCategories(): void {
|
||||
this.twigsService.getCategories(this.budgetId).then(categories => {
|
||||
this.categories = categories;
|
||||
for (const category of this.categories) {
|
||||
this.getCategoryBalance(category).then(balance => this.categoryBalances.set(category.id, balance));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getCategoryBalance(category: Category): Promise<number> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let transactions: Transaction[]
|
||||
try {
|
||||
transactions = await this.twigsService.getTransactions(this.budgetId, category.id)
|
||||
} catch(e) {
|
||||
reject(e)
|
||||
}
|
||||
let balance = 0;
|
||||
for (const transaction of transactions) {
|
||||
if (transaction.expense) {
|
||||
balance -= transaction.amount;
|
||||
} else {
|
||||
balance += transaction.amount;
|
||||
}
|
||||
}
|
||||
resolve(balance);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<div class="category-breakdown">
|
||||
<canvas baseChart
|
||||
[datasets]="barChartData"
|
||||
[options]="barChartOptions"
|
||||
[labels]="barChartLabels"
|
||||
[legend]="barChartLegend"
|
||||
[type]="barChartType">
|
||||
</canvas>
|
||||
</div>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { CategoryBreakdownComponent } from './category-breakdown.component';
|
||||
|
||||
describe('CategoryBreakdownComponent', () => {
|
||||
let component: CategoryBreakdownComponent;
|
||||
let fixture: ComponentFixture<CategoryBreakdownComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CategoryBreakdownComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategoryBreakdownComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
import { CategoriesComponent } from '../categories.component';
|
||||
import { ChartConfiguration, ChartType, ChartDataset } from 'chart.js';
|
||||
import { BaseChartDirective } from 'ng2-charts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-breakdown',
|
||||
templateUrl: './category-breakdown.component.html',
|
||||
styleUrls: ['./category-breakdown.component.css']
|
||||
})
|
||||
export class CategoryBreakdownComponent implements OnInit, OnChanges {
|
||||
barChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
// beginAtZero: true
|
||||
}
|
||||
},
|
||||
y: {}
|
||||
},
|
||||
indexAxis: 'y'
|
||||
};
|
||||
@Input() barChartLabels: string[];
|
||||
@Input() barChartData: ChartDataset[] = [
|
||||
{ data: [0, 0, 0, 0], label: '' },
|
||||
];
|
||||
barChartType: ChartType = 'bar';
|
||||
barChartLegend = true;
|
||||
@ViewChild(BaseChartDirective) chart: BaseChartDirective;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
console.log(changes);
|
||||
// if (changes.barChartLabels) {
|
||||
// this.barChartLabels = changes.barChartLabels.currentValue;
|
||||
// }
|
||||
if (changes.barChartData) {
|
||||
this.barChartData = changes.barChartData.currentValue;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
.category-description {
|
||||
padding: 0 1em;
|
||||
white-space: pre-wrap;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<p class="category-description" *ngIf="category && category.description" [innerHtml]="category.description"></p>
|
||||
<app-transaction-list *ngIf="budgetId && category" [budgetIds]="[budgetId]" [categoryIds]="[category.id]"></app-transaction-list>
|
||||
<a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budgetId}">
|
||||
<mat-icon aria-label="Add">add</mat-icon>
|
||||
</a>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { CategoryDetailsComponent } from './category-details.component';
|
||||
|
||||
describe('CategoryDetailsComponent', () => {
|
||||
let component: CategoryDetailsComponent;
|
||||
let fixture: ComponentFixture<CategoryDetailsComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CategoryDetailsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategoryDetailsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
import { Component, OnInit, Inject, ApplicationModule, OnDestroy } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
|
||||
import { Transaction } from '../../transactions/transaction';
|
||||
import { AppComponent } from '../../app.component';
|
||||
import { Actionable } from '../../shared/actionable';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-details',
|
||||
templateUrl: './category-details.component.html',
|
||||
styleUrls: ['./category-details.component.css']
|
||||
})
|
||||
export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
|
||||
|
||||
budgetId: string;
|
||||
category: Category;
|
||||
public transactions: Transaction[];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
private router: Router
|
||||
) { }
|
||||
|
||||
doAction(): void {
|
||||
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
|
||||
}
|
||||
|
||||
getActionLabel(): string {
|
||||
return "Edit";
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.app.setBackEnabled(true);
|
||||
this.app.setActionable(this)
|
||||
this.getCategory();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.app.setActionable(null)
|
||||
}
|
||||
|
||||
getCategory(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
this.twigsService.getCategory(id)
|
||||
.then(category => {
|
||||
category.amount /= 100;
|
||||
this.app.setTitle(category.title)
|
||||
this.category = category;
|
||||
this.budgetId = category.budgetId;
|
||||
});
|
||||
}
|
||||
}
|
11
src/app/categories/category-form/category-form.component.css
Normal file
11
src/app/categories/category-form/category-form.component.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.button-delete {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.category-form * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
mat-radio-button {
|
||||
padding-bottom: 15px;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
<div *ngIf="!currentCategory">
|
||||
<p>Select a category from the list to view details about it or edit it.</p>
|
||||
</div>
|
||||
<div *ngIf="currentCategory" class="form category-form">
|
||||
<mat-form-field (keyup.enter)="save()">
|
||||
<input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required autocapitalize="words">
|
||||
</mat-form-field>
|
||||
<mat-form-field (keyup.enter)="save()">
|
||||
<textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field (keyup.enter)="save()">
|
||||
<input matInput type="input" [(ngModel)]="currentCategory.amount" placeholder="Amount" required step="0.01">
|
||||
</mat-form-field>
|
||||
<mat-radio-group [(ngModel)]="currentCategory.expense">
|
||||
<mat-radio-button [value]="true">Expense</mat-radio-button>
|
||||
<mat-radio-button [value]="false">Income</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<mat-checkbox *ngIf="currentCategory.id" [(ngModel)]="currentCategory.archived">Archived</mat-checkbox>
|
||||
<!--
|
||||
<mat-form-field>
|
||||
<input type="color" matInput [(ngModel)]="currentCategory.color" placeholder="Color">
|
||||
</mat-form-field>
|
||||
-->
|
||||
<button mat-raised-button color="accent" (click)="save()">Save</button>
|
||||
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
|
||||
</div>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { CategoryFormComponent } from './category-form.component';
|
||||
|
||||
describe('CategoryFormComponent', () => {
|
||||
let component: CategoryFormComponent;
|
||||
let fixture: ComponentFixture<CategoryFormComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CategoryFormComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategoryFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
62
src/app/categories/category-form/category-form.component.ts
Normal file
62
src/app/categories/category-form/category-form.component.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Component, OnInit, Input, Inject } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
import { AppComponent } from 'src/app/app.component';
|
||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||
import { decimalToInteger } from 'src/app/shared/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-form',
|
||||
templateUrl: './category-form.component.html',
|
||||
styleUrls: ['./category-form.component.css']
|
||||
})
|
||||
export class CategoryFormComponent implements OnInit {
|
||||
|
||||
@Input() budgetId: string;
|
||||
@Input() title: string;
|
||||
@Input() currentCategory: Category;
|
||||
@Input() create: boolean;
|
||||
|
||||
constructor(
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.app.setBackEnabled(true);
|
||||
this.app.setTitle(this.title)
|
||||
}
|
||||
|
||||
save(): void {
|
||||
let promise;
|
||||
this.currentCategory.amount = decimalToInteger(String(this.currentCategory.amount))
|
||||
if (this.create) {
|
||||
// This is a new category, save it
|
||||
promise = this.twigsService.createCategory(
|
||||
this.currentCategory.id,
|
||||
this.budgetId,
|
||||
this.currentCategory.title,
|
||||
this.currentCategory.description,
|
||||
this.currentCategory.amount,
|
||||
this.currentCategory.expense
|
||||
);
|
||||
} else {
|
||||
// This is an existing category, update it
|
||||
const updatedCategory: Category = {
|
||||
...this.currentCategory,
|
||||
}
|
||||
promise = this.twigsService.updateCategory(
|
||||
this.currentCategory.id,
|
||||
this.currentCategory
|
||||
);
|
||||
}
|
||||
promise.then(_ => {
|
||||
this.app.goBack();
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
this.twigsService.deleteCategory(this.currentCategory.id).then(() => {
|
||||
this.app.goBack();
|
||||
});
|
||||
}
|
||||
}
|
31
src/app/categories/category-list/category-list.component.css
Normal file
31
src/app/categories/category-list/category-list.component.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.categories mat-progress-bar.mat-progress-bar {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p.mat-line.category-list-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
p.mat-line.category-list-title .remaining {
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
::ng-deep .income .mat-progress-bar-fill::after {
|
||||
background-color: #81C784 !important;
|
||||
}
|
||||
|
||||
::ng-deep .expense .mat-progress-bar-fill::after {
|
||||
background-color: #E57373 !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-progress-bar-buffer {
|
||||
background-color: #F1F1F1 !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::ng-deep .mat-progress-bar-buffer {
|
||||
background-color: #333333 !important;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<mat-nav-list class="categories">
|
||||
<a mat-list-item *ngFor="let category of categories" routerLink="/categories/{{ category.id }}">
|
||||
<p matLine class="category-list-title">
|
||||
<span>
|
||||
{{ category.title }}
|
||||
</span>
|
||||
<span class="remaining">
|
||||
{{ getCategoryRemainingBalance(category) | currency }} remaining of {{ category.amount / 100 | currency }}
|
||||
</span>
|
||||
</p>
|
||||
<mat-progress-bar matLine color="accent" [ngClass]="{'income': !category.expense, 'expense': category.expense}" mode="determinate" #categoryProgress [attr.id]="'cat-' + category.id" value="{{ getCategoryCompletion(category) }}"></mat-progress-bar>
|
||||
</a>
|
||||
</mat-nav-list>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { CategoryListComponent } from './category-list.component';
|
||||
|
||||
describe('CategoryListComponent', () => {
|
||||
let component: CategoryListComponent;
|
||||
let fixture: ComponentFixture<CategoryListComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CategoryListComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CategoryListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
54
src/app/categories/category-list/category-list.component.ts
Normal file
54
src/app/categories/category-list/category-list.component.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Component, OnInit, Input } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-list',
|
||||
templateUrl: './category-list.component.html',
|
||||
styleUrls: ['./category-list.component.css']
|
||||
})
|
||||
export class CategoryListComponent implements OnInit {
|
||||
|
||||
@Input() budgetId: string;
|
||||
@Input() categories: Category[];
|
||||
@Input() categoryBalances: Map<string, number>;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
getCategoryRemainingBalance(category: Category): number {
|
||||
let categoryBalance = this.categoryBalances.get(category.id);
|
||||
if (!categoryBalance) {
|
||||
categoryBalance = 0;
|
||||
}
|
||||
|
||||
if (category.expense) {
|
||||
return (category.amount / 100) + (categoryBalance / 100);
|
||||
} else {
|
||||
return (category.amount / 100) - (categoryBalance / 100);
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryCompletion(category: Category): number {
|
||||
const amount = category.amount > 0 ? category.amount : 1;
|
||||
|
||||
let categoryBalance = this.categoryBalances.get(category.id);
|
||||
if (!categoryBalance) {
|
||||
categoryBalance = 0;
|
||||
}
|
||||
|
||||
// Invert the negative/positive values for calculating progress
|
||||
// since the limit for a category is saved as a positive but the
|
||||
// balance is used in the calculation.
|
||||
if (category.expense) {
|
||||
if (categoryBalance < 0) {
|
||||
categoryBalance = Math.abs(categoryBalance);
|
||||
} else {
|
||||
categoryBalance -= (categoryBalance * 2);
|
||||
}
|
||||
}
|
||||
|
||||
return categoryBalance / amount * 100;
|
||||
}
|
||||
}
|
11
src/app/categories/category.ts
Normal file
11
src/app/categories/category.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { randomId } from '../shared/utils';
|
||||
|
||||
export class Category {
|
||||
id: string = randomId();
|
||||
title: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
expense: boolean;
|
||||
archived: boolean;
|
||||
budgetId: string;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<app-category-form [title]="'Edit Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="false"></app-category-form>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { EditCategoryComponent } from './edit-category.component';
|
||||
|
||||
describe('EditCategoryComponent', () => {
|
||||
let component: EditCategoryComponent;
|
||||
let fixture: ComponentFixture<EditCategoryComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ EditCategoryComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditCategoryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
38
src/app/categories/edit-category/edit-category.component.ts
Normal file
38
src/app/categories/edit-category/edit-category.component.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { AppComponent } from '../../app.component';
|
||||
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-category',
|
||||
templateUrl: './edit-category.component.html',
|
||||
styleUrls: ['./edit-category.component.css']
|
||||
})
|
||||
export class EditCategoryComponent implements OnInit {
|
||||
|
||||
budgetId: string;
|
||||
category: Category;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.app.setBackEnabled(true);
|
||||
this.getCategory();
|
||||
}
|
||||
|
||||
getCategory(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
this.twigsService.getCategory(id)
|
||||
.then(category => {
|
||||
category.amount /= 100;
|
||||
this.app.setTitle(category.title)
|
||||
this.category = category;
|
||||
this.budgetId = category.budgetId;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<app-category-form [title]="'Add Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="true"></app-category-form>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { NewCategoryComponent } from './new-category.component';
|
||||
|
||||
describe('NewCategoryComponent', () => {
|
||||
let component: NewCategoryComponent;
|
||||
let fixture: ComponentFixture<NewCategoryComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ NewCategoryComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NewCategoryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
27
src/app/categories/new-category/new-category.component.ts
Normal file
27
src/app/categories/new-category/new-category.component.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { Category } from '../category';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new-category',
|
||||
templateUrl: './new-category.component.html',
|
||||
styleUrls: ['./new-category.component.css']
|
||||
})
|
||||
export class NewCategoryComponent implements OnInit {
|
||||
|
||||
budgetId: string;
|
||||
category: Category;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.budgetId = this.route.snapshot.queryParamMap.get('budgetId');
|
||||
console.log(`Creating category for budget ${this.budgetId}`)
|
||||
this.category = new Category();
|
||||
// TODO: Set random color for category, improve color picker
|
||||
// this.category.color =
|
||||
}
|
||||
|
||||
}
|
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}`
|
||||
}
|
||||
}
|
9
src/app/shared/actionable.ts
Normal file
9
src/app/shared/actionable.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface Actionable {
|
||||
getActionLabel(): string;
|
||||
doAction(): void;
|
||||
}
|
||||
|
||||
export function isActionable(obj: any): obj is Actionable {
|
||||
return typeof obj.prototype.getActionLabel === 'function'
|
||||
&& typeof obj.prototype.doAction === 'function'
|
||||
}
|
21
src/app/shared/auth.interceptor.ts
Normal file
21
src/app/shared/auth.interceptor.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
|
||||
constructor(
|
||||
private storage: Storage
|
||||
) { }
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
let token = this.storage.getItem('Authorization')
|
||||
if (!token) {
|
||||
return next.handle(req);
|
||||
}
|
||||
let headers = req.headers;
|
||||
headers = headers.append('Authorization', `Bearer ${token}`);
|
||||
return next.handle(req.clone({headers: headers}));
|
||||
}
|
||||
}
|
370
src/app/shared/twigs.http.service.ts
Normal file
370
src/app/shared/twigs.http.service.ts
Normal file
|
@ -0,0 +1,370 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { User, UserPermission, Permission, AuthToken } from '../users/user';
|
||||
import { TwigsService } from './twigs.service';
|
||||
import { Budget } from '../budgets/budget';
|
||||
import { Category } from '../categories/category';
|
||||
import { Transaction } from '../transactions/transaction';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TwigsHttpService implements TwigsService {
|
||||
|
||||
private apiUrl = environment.apiUrl;
|
||||
|
||||
constructor(
|
||||
private storage: Storage
|
||||
) { }
|
||||
|
||||
async login(email: string, password: string): Promise<User> {
|
||||
const url = new URL('/api/users/login', this.apiUrl)
|
||||
const auth: AuthToken = await this.request(url, HttpMethod.POST, {
|
||||
'username': email,
|
||||
'password': password
|
||||
});
|
||||
this.storage.setItem('Authorization', auth.token);
|
||||
this.storage.setItem('userId', auth.userId);
|
||||
return await this.getProfile(auth.userId);
|
||||
}
|
||||
|
||||
register(username: string, email: string, password: string): Promise<User> {
|
||||
const body = {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password
|
||||
};
|
||||
const url = new URL('/api/users/register', this.apiUrl)
|
||||
return this.request<User>(url, HttpMethod.POST, body);
|
||||
}
|
||||
|
||||
logout(): Promise<void> {
|
||||
this.storage.removeItem('Authorization');
|
||||
this.storage.removeItem('userId');
|
||||
return Promise.resolve()
|
||||
// TODO: Implement this to revoke the token server-side as well
|
||||
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
|
||||
}
|
||||
|
||||
// Budgets
|
||||
getBudgets(): Promise<Budget[]> {
|
||||
const url = new URL('/api/budgets', this.apiUrl)
|
||||
return this.request(url, HttpMethod.GET)
|
||||
}
|
||||
|
||||
getBudgetBalance(
|
||||
id: string,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<number> {
|
||||
const url = new URL('/api/transactions/sum', this.apiUrl)
|
||||
url.searchParams.set('budgetId', id)
|
||||
if (from) {
|
||||
url.searchParams.set('from', from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
url.searchParams.set('to', to.toISOString());
|
||||
}
|
||||
return this.request(url, HttpMethod.GET).then((res: any) => res.balance)
|
||||
}
|
||||
|
||||
getBudget(id: string): Promise<Budget> {
|
||||
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.GET)
|
||||
}
|
||||
|
||||
createBudget(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
users: UserPermission[],
|
||||
): Promise<Budget> {
|
||||
const url = new URL('/api/budgets', this.apiUrl)
|
||||
const body = {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'users': users.map(userPermission => {
|
||||
return {
|
||||
user: userPermission.user,
|
||||
permission: Permission[userPermission.permission]
|
||||
};
|
||||
})
|
||||
};
|
||||
return this.request(url, HttpMethod.POST, body)
|
||||
}
|
||||
|
||||
updateBudget(id: string, budget: Budget): Promise<Budget> {
|
||||
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||
const body = {
|
||||
'name': budget.name,
|
||||
'description': budget.description,
|
||||
'users': budget.users.map(userPermission => {
|
||||
return {
|
||||
user: userPermission.user,
|
||||
permission: Permission[userPermission.permission]
|
||||
};
|
||||
})
|
||||
};
|
||||
return this.request(url, HttpMethod.PUT, body)
|
||||
}
|
||||
|
||||
deleteBudget(id: String): Promise<void> {
|
||||
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.DELETE)
|
||||
}
|
||||
|
||||
// Categories
|
||||
getCategories(budgetId: string, count?: number): Promise<Category[]> {
|
||||
const url = new URL(`/api/categories`, this.apiUrl)
|
||||
url.searchParams.set('budgetIds', budgetId)
|
||||
url.searchParams.set('archived', 'false')
|
||||
return this.request(url, HttpMethod.GET);
|
||||
}
|
||||
|
||||
getCategory(id: string): Promise<Category> {
|
||||
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.GET);
|
||||
}
|
||||
|
||||
async getCategoryBalance(
|
||||
id: string,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<number> {
|
||||
const url = new URL(`/api/transactions/sum`, this.apiUrl)
|
||||
url.searchParams.set('categoryId', id)
|
||||
if (from) {
|
||||
url.searchParams.set('from', from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
url.searchParams.set('to', to.toISOString());
|
||||
}
|
||||
const res: any = await this.request(url, HttpMethod.GET);
|
||||
return res.balance;
|
||||
}
|
||||
|
||||
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
|
||||
const url = new URL(`/api/categories`, this.apiUrl)
|
||||
const body = {
|
||||
'id': id,
|
||||
'title': name,
|
||||
'description': description,
|
||||
'amount': amount,
|
||||
'expense': isExpense,
|
||||
'budgetId': budgetId
|
||||
};
|
||||
return this.request(url, HttpMethod.POST, body);
|
||||
}
|
||||
|
||||
updateCategory(id: string, changes: object): Promise<Category> {
|
||||
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.PUT, changes);
|
||||
}
|
||||
|
||||
deleteCategory(id: string): Promise<void> {
|
||||
const url = new URL(`/api/categories/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.DELETE);
|
||||
}
|
||||
|
||||
// Transactions
|
||||
async getTransactions(
|
||||
budgetId?: string,
|
||||
categoryId?: string,
|
||||
count?: number,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<Transaction[]> {
|
||||
const url = new URL(`/api/transactions`, this.apiUrl)
|
||||
if (budgetId) {
|
||||
url.searchParams.set('budgetIds', budgetId);
|
||||
}
|
||||
if (categoryId) {
|
||||
url.searchParams.set('categoryIds', categoryId);
|
||||
}
|
||||
if (from) {
|
||||
url.searchParams.set('from', from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
url.searchParams.set('to', to.toISOString());
|
||||
}
|
||||
const transactions: Transaction[] = await this.request(url, HttpMethod.GET)
|
||||
transactions.forEach(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
})
|
||||
return transactions
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<Transaction> {
|
||||
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
|
||||
const transaction: Transaction = await this.request(url, HttpMethod.GET)
|
||||
transaction.date = new Date(transaction.date)
|
||||
return transaction
|
||||
}
|
||||
|
||||
async createTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
date: Date,
|
||||
expense: boolean,
|
||||
category: string
|
||||
): Promise<Transaction> {
|
||||
const url = new URL(`/api/transactions`, this.apiUrl)
|
||||
const body = {
|
||||
'id': id,
|
||||
'title': name,
|
||||
'description': description,
|
||||
'date': date.toISOString(),
|
||||
'amount': amount,
|
||||
'expense': expense,
|
||||
'categoryId': category,
|
||||
'budgetId': budgetId
|
||||
};
|
||||
const transaction: Transaction = await this.request(url, HttpMethod.POST, body)
|
||||
transaction.date = new Date(transaction.date)
|
||||
return transaction
|
||||
}
|
||||
|
||||
async updateTransaction(id: string, transaction: Transaction): Promise<Transaction> {
|
||||
const body: any = transaction;
|
||||
body.date = transaction.date.toISOString()
|
||||
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
|
||||
const updatedTransaction: Transaction = await this.request(url, HttpMethod.PUT, body)
|
||||
updatedTransaction.date = new Date(updatedTransaction.date)
|
||||
return updatedTransaction
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Promise<void> {
|
||||
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.DELETE)
|
||||
}
|
||||
|
||||
// Recurring Transactions
|
||||
async getRecurringTransactions(
|
||||
budgetId?: string,
|
||||
categoryId?: string,
|
||||
count?: number,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<RecurringTransaction[]> {
|
||||
const url = new URL(`/api/recurringtransactions`, this.apiUrl)
|
||||
if (budgetId) {
|
||||
url.searchParams.set('budgetIds', budgetId);
|
||||
}
|
||||
if (categoryId) {
|
||||
url.searchParams.set('categoryIds', categoryId);
|
||||
}
|
||||
if (from) {
|
||||
url.searchParams.set('from', from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
url.searchParams.set('to', to.toISOString());
|
||||
}
|
||||
const transactions: RecurringTransaction[] = await this.request(url, HttpMethod.GET)
|
||||
transactions.forEach(transaction => {
|
||||
transaction.frequency = Frequency.parse(transaction.frequency as any)
|
||||
})
|
||||
return transactions
|
||||
}
|
||||
|
||||
async getRecurringTransaction(id: string): Promise<RecurringTransaction> {
|
||||
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
|
||||
const transaction: RecurringTransaction = await this.request(url, HttpMethod.GET)
|
||||
transaction.frequency = Frequency.parse(transaction.frequency as any)
|
||||
return transaction
|
||||
}
|
||||
|
||||
async createRecurringTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
frequency: Frequency,
|
||||
start: Date,
|
||||
expense: boolean,
|
||||
category: string,
|
||||
end?: Date,
|
||||
): Promise<RecurringTransaction> {
|
||||
const url = new URL(`/api/transactions`, this.apiUrl)
|
||||
const body = {
|
||||
'id': id,
|
||||
'title': name,
|
||||
'description': description,
|
||||
'frequency': frequency.toString(),
|
||||
'start': start.toISOString(),
|
||||
'finish': end?.toISOString(),
|
||||
'amount': amount,
|
||||
'expense': expense,
|
||||
'categoryId': category,
|
||||
'budgetId': budgetId
|
||||
};
|
||||
const transaction: RecurringTransaction = await this.request(url, HttpMethod.POST, body)
|
||||
transaction.frequency = Frequency.parse(transaction.frequency as any)
|
||||
return transaction
|
||||
}
|
||||
|
||||
async updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
|
||||
const body: any = transaction;
|
||||
body.frequency = transaction.frequency.toString()
|
||||
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
|
||||
const updatedTransaction: RecurringTransaction = await this.request(url, HttpMethod.PUT, body)
|
||||
updatedTransaction.frequency = Frequency.parse(updatedTransaction.frequency as any)
|
||||
return updatedTransaction
|
||||
}
|
||||
|
||||
deleteRecurringTransaction(id: string): Promise<void> {
|
||||
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.DELETE)
|
||||
}
|
||||
|
||||
// Users
|
||||
getProfile(id: string): Promise<User> {
|
||||
const url = new URL(`/api/users/${id}`, this.apiUrl)
|
||||
return this.request(url, HttpMethod.GET)
|
||||
}
|
||||
|
||||
getUsersByUsername(username: string): Promise<User[]> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
private async request<T>(url: URL, method: HttpMethod, body?: any): Promise<T> {
|
||||
const headers = {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
const token = this.storage.getItem('Authorization')
|
||||
if (token) {
|
||||
headers['authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let jsonBody: string;
|
||||
if (body) {
|
||||
jsonBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: headers,
|
||||
method: method,
|
||||
body: jsonBody
|
||||
})
|
||||
|
||||
if (res.status === 204) {
|
||||
// No content
|
||||
return
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
}
|
||||
|
||||
enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
DELETE = "DELETE",
|
||||
}
|
348
src/app/shared/twigs.local.service.ts
Normal file
348
src/app/shared/twigs.local.service.ts
Normal file
|
@ -0,0 +1,348 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { User, UserPermission } from '../users/user';
|
||||
import { TwigsService } from './twigs.service';
|
||||
import { Budget } from '../budgets/budget';
|
||||
import { Category } from '../categories/category';
|
||||
import { Transaction } from '../transactions/transaction';
|
||||
import { randomId } from '../shared/utils';
|
||||
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
|
||||
|
||||
/**
|
||||
* This is intended to be a very simple implementation of the TwigsService used for testing out the UI and quickly iterating on it.
|
||||
* It may also prove useful for automated testing.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TwigsLocalService implements TwigsService {
|
||||
|
||||
constructor(
|
||||
) { }
|
||||
|
||||
private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
|
||||
private budgets: Budget[] = [];
|
||||
private transactions: Transaction[] = [];
|
||||
private categories: Category[] = [];
|
||||
|
||||
// Auth
|
||||
login(email: string, password: string): Promise<User> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const filteredUsers = this.users.filter(user => {
|
||||
return (user.email === email || user.username === email);
|
||||
});
|
||||
if (filteredUsers.length !== 0) {
|
||||
resolve(filteredUsers[0]);
|
||||
} else {
|
||||
reject('No users found');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
register(username: string, email: string, password: string): Promise<User> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const user = new User();
|
||||
user.username = username;
|
||||
user.email = email;
|
||||
user.id = randomId();
|
||||
this.users.push(user);
|
||||
resolve(user);
|
||||
});
|
||||
}
|
||||
|
||||
logout(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Budgets
|
||||
getBudgets(): Promise<Budget[]> {
|
||||
return Promise.resolve(this.budgets)
|
||||
}
|
||||
|
||||
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number> {
|
||||
return Promise.resolve(200)
|
||||
}
|
||||
|
||||
getBudget(id: string): Promise<Budget> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const budget = this.budgets.filter(it => {
|
||||
return it.id === id;
|
||||
})[0];
|
||||
if (budget) {
|
||||
resolve(budget);
|
||||
} else {
|
||||
reject('No budget found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createBudget(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
users: UserPermission[],
|
||||
): Promise<Budget> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const budget = new Budget();
|
||||
budget.name = name;
|
||||
budget.description = description;
|
||||
budget.users = users;
|
||||
budget.id = id;
|
||||
this.budgets.push(budget);
|
||||
resolve(budget);
|
||||
});
|
||||
}
|
||||
|
||||
updateBudget(id: string, budget: Budget): Promise<Budget> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const budget = this.budgets.filter(it => {
|
||||
return it.id === id;
|
||||
})[0];
|
||||
if (budget) {
|
||||
const index = this.budgets.indexOf(budget);
|
||||
this.updateValues(
|
||||
budget,
|
||||
budget,
|
||||
[
|
||||
'name',
|
||||
'description',
|
||||
'users',
|
||||
]
|
||||
);
|
||||
this.budgets[index] = budget;
|
||||
resolve(budget);
|
||||
} else {
|
||||
reject('No budget found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteBudget(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const budget = this.budgets.filter(it => {
|
||||
return budget.id === id;
|
||||
})[0];
|
||||
if (budget) {
|
||||
const index = this.budgets.indexOf(budget);
|
||||
delete this.budgets[index];
|
||||
resolve();
|
||||
} else {
|
||||
reject('No budget found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Categories
|
||||
getCategories(budgetId: string, count?: number): Promise<Category[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(this.categories.filter(category => {
|
||||
return category.budgetId === budgetId;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
getCategory(id: string): Promise<Category> {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(this.findById(this.categories, id));
|
||||
});
|
||||
}
|
||||
|
||||
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number> {
|
||||
return Promise.resolve(20);
|
||||
}
|
||||
|
||||
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const category = new Category();
|
||||
category.title = name;
|
||||
category.description = description;
|
||||
category.amount = amount;
|
||||
category.expense = isExpense;
|
||||
category.budgetId = budgetId;
|
||||
category.id = id;
|
||||
this.categories.push(category);
|
||||
resolve(category);
|
||||
});
|
||||
}
|
||||
|
||||
updateCategory(id: string, changes: object): Promise<Category> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const category = this.findById(this.categories, id);
|
||||
if (category) {
|
||||
const index = this.categories.indexOf(category);
|
||||
this.updateValues(
|
||||
category,
|
||||
changes,
|
||||
[
|
||||
'name',
|
||||
'amount',
|
||||
'isExpense',
|
||||
'budgetId',
|
||||
]
|
||||
);
|
||||
this.categories[index] = category;
|
||||
resolve(category);
|
||||
} else {
|
||||
reject('No category found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteCategory(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const category = this.findById(this.categories, id);
|
||||
if (category) {
|
||||
const index = this.categories.indexOf(category);
|
||||
delete this.transactions[index];
|
||||
resolve();
|
||||
} else {
|
||||
reject('No category found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Transactions
|
||||
getTransactions(budgetId?: string, categoryId?: string, count?: number): Promise<Transaction[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(this.transactions.filter(transaction => {
|
||||
let include = true;
|
||||
if (budgetId) {
|
||||
include = transaction.budgetId === budgetId;
|
||||
}
|
||||
if (include && categoryId) {
|
||||
include = transaction.categoryId === categoryId;
|
||||
}
|
||||
return include;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
getTransaction(id: string): Promise<Transaction> {
|
||||
return Promise.resolve(this.findById(this.transactions, id));
|
||||
}
|
||||
|
||||
createTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
date: Date,
|
||||
isExpense: boolean,
|
||||
category: string
|
||||
): Promise<Transaction> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = new Transaction();
|
||||
transaction.title = name;
|
||||
transaction.description = description;
|
||||
transaction.amount = amount;
|
||||
transaction.date = date;
|
||||
transaction.expense = isExpense;
|
||||
transaction.categoryId = category;
|
||||
transaction.budgetId = budgetId;
|
||||
transaction.id = randomId();
|
||||
this.transactions.push(transaction);
|
||||
resolve(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
updateTransaction(id: string, changes: object): Promise<Transaction> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.findById(this.transactions, id);
|
||||
if (transaction) {
|
||||
const index = this.transactions.indexOf(transaction);
|
||||
this.updateValues(
|
||||
transaction,
|
||||
changes,
|
||||
[
|
||||
'title',
|
||||
'description',
|
||||
'date',
|
||||
'amount',
|
||||
'isExpense',
|
||||
'categoryId',
|
||||
'budgetId',
|
||||
'createdBy'
|
||||
]
|
||||
);
|
||||
this.transactions[index] = transaction;
|
||||
resolve(transaction);
|
||||
} else {
|
||||
reject('No transaction found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.findById(this.transactions, id);
|
||||
if (transaction) {
|
||||
const index = this.transactions.indexOf(transaction);
|
||||
delete this.transactions[index];
|
||||
resolve();
|
||||
} else {
|
||||
reject('No transaction found for given id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Recurring Transactions
|
||||
getRecurringTransactions(
|
||||
budgetId?: string,
|
||||
categoryId?: string,
|
||||
count?: number,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<RecurringTransaction[]> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
getRecurringTransaction(id: string): Promise<RecurringTransaction> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
createRecurringTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
frequency: Frequency,
|
||||
start: Date,
|
||||
expense: boolean,
|
||||
category: string,
|
||||
end?: Date,
|
||||
): Promise<RecurringTransaction> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
deleteRecurringTransaction(id: string): Promise<void> {
|
||||
return Promise.reject("Not yet implemented")
|
||||
}
|
||||
|
||||
// Users
|
||||
getProfile(id: string): Promise<User> {
|
||||
return Promise.reject("Not yet implemented");
|
||||
}
|
||||
|
||||
getUsersByUsername(username: string): Promise<User[]> {
|
||||
return Promise.resolve(this.users.filter(user => user.username.indexOf(username) > -1))
|
||||
}
|
||||
|
||||
private updateValues(old: object, changes: object, keys: string[]) {
|
||||
keys.forEach(key => {
|
||||
if (changes[key]) {
|
||||
old[key] = changes[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private findById<T>(items: T[], id: string): T {
|
||||
return items.filter(item => {
|
||||
return item['id'] === id;
|
||||
})[0];
|
||||
}
|
||||
}
|
85
src/app/shared/twigs.service.ts
Normal file
85
src/app/shared/twigs.service.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { InjectionToken } from '@angular/core';
|
||||
import { User, UserPermission } from '../users/user';
|
||||
import { Budget } from '../budgets/budget';
|
||||
import { Category } from '../categories/category';
|
||||
import { RecurringTransaction, Frequency } from '../recurringtransactions/recurringtransaction';
|
||||
import { Transaction } from '../transactions/transaction';
|
||||
|
||||
export interface TwigsService {
|
||||
// Auth
|
||||
login(email: string, password: string): Promise<User>;
|
||||
register(username: string, email: string, password: string): Promise<User>;
|
||||
logout(): Promise<void>;
|
||||
|
||||
// Budgets
|
||||
getBudgets(): Promise<Budget[]>;
|
||||
getBudget(id: string): Promise<Budget>;
|
||||
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
|
||||
createBudget(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
users: UserPermission[],
|
||||
): Promise<Budget>;
|
||||
updateBudget(id: string, budget: Budget): Promise<Budget>;
|
||||
deleteBudget(id: string): Promise<void>;
|
||||
|
||||
// Categories
|
||||
getCategories(budgetId?: string, count?: number): Promise<Category[]>;
|
||||
getCategory(id: string): Promise<Category>;
|
||||
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
|
||||
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
|
||||
updateCategory(id: string, category: Category): Promise<Category>;
|
||||
deleteCategory(id: string): Promise<void>;
|
||||
|
||||
// Transactions
|
||||
getTransactions(
|
||||
budgetId?: string,
|
||||
categoryId?: string,
|
||||
count?: number,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<Transaction[]>;
|
||||
getTransaction(id: string): Promise<Transaction>;
|
||||
createTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
date: Date,
|
||||
isExpense: boolean,
|
||||
category: string
|
||||
): Promise<Transaction>;
|
||||
updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
|
||||
deleteTransaction(id: string): Promise<void>;
|
||||
|
||||
// Recurring Transactions
|
||||
getRecurringTransactions(
|
||||
budgetId?: string,
|
||||
categoryId?: string,
|
||||
count?: number,
|
||||
from?: Date,
|
||||
to?: Date
|
||||
): Promise<RecurringTransaction[]>;
|
||||
getRecurringTransaction(id: string): Promise<RecurringTransaction>;
|
||||
createRecurringTransaction(
|
||||
id: string,
|
||||
budgetId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
frequency: Frequency,
|
||||
start: Date,
|
||||
expense: boolean,
|
||||
category: string,
|
||||
end?: Date,
|
||||
): Promise<RecurringTransaction>;
|
||||
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction>;
|
||||
deleteRecurringTransaction(id: string): Promise<void>;
|
||||
|
||||
getProfile(id: string): Promise<User>;
|
||||
getUsersByUsername(username: string): Promise<User[]>;
|
||||
}
|
||||
|
||||
export let TWIGS_SERVICE = new InjectionToken<TwigsService>('twigs.service');
|
15
src/app/shared/utils.ts
Normal file
15
src/app/shared/utils.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
export function randomId(): string {
|
||||
var bytes = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => CHARACTERS[byte % CHARACTERS.length]).join('')
|
||||
}
|
||||
|
||||
export function decimalToInteger(amount: string): number {
|
||||
if (amount[amount.length - 3] === "." || amount[amount.length - 3] === ",") {
|
||||
return Number(amount.replace(/[,.]/g, ""))
|
||||
} else {
|
||||
return Number(amount + "00")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.transaction-form {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.transaction-form * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
mat-radio-button {
|
||||
padding-bottom: 15px;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
<div [hidden]="currentTransaction">
|
||||
<p>Select a transaction from the list to view details about it or edit it.</p>
|
||||
</div>
|
||||
<div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form">
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required autocapitalize="words">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description" autocapitalize="sentences"></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<input matInput type="number" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required step="0.01">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"
|
||||
(ngModelChange)="transactionDate = $event" placeholder="Date" required>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<input matInput type="time" [(ngModel)]="currentTime" placeholder="Time" required>
|
||||
</mat-form-field>
|
||||
<mat-radio-group [(ngModel)]="currentTransaction.expense" (change)="updateCategories($event)">
|
||||
<mat-radio-button [value]="true">Expense</mat-radio-button>
|
||||
<mat-radio-button [value]="false">Income</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<mat-form-field>
|
||||
<mat-select placeholder="Category" [(ngModel)]="currentTransaction.categoryId">
|
||||
<mat-option *ngFor="let category of categories" [value]="category.id">
|
||||
{{ category.title }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="accent" (click)="save()">Save</button>
|
||||
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
|
||||
</div>
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { AddEditTransactionComponent } from './add-edit-transaction.component';
|
||||
|
||||
describe('AddEditTransactionComponent', () => {
|
||||
let component: AddEditTransactionComponent;
|
||||
let fixture: ComponentFixture<AddEditTransactionComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ AddEditTransactionComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(AddEditTransactionComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
import { Component, OnInit, Input, OnChanges, OnDestroy, Inject, SimpleChanges } from '@angular/core';
|
||||
import { Transaction } from '../transaction';
|
||||
import { TransactionType } from '../transaction.type';
|
||||
import { Category } from 'src/app/categories/category';
|
||||
import { AppComponent } from 'src/app/app.component';
|
||||
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
|
||||
import { MatLegacyRadioChange as MatRadioChange } from '@angular/material/legacy-radio';
|
||||
import { decimalToInteger } from 'src/app/shared/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-edit-transaction',
|
||||
templateUrl: './add-edit-transaction.component.html',
|
||||
styleUrls: ['./add-edit-transaction.component.css']
|
||||
})
|
||||
export class AddEditTransactionComponent implements OnInit, OnChanges {
|
||||
@Input() title: string;
|
||||
@Input() currentTransaction: Transaction;
|
||||
@Input() budgetId: string;
|
||||
@Input() create: boolean
|
||||
public transactionType = TransactionType;
|
||||
public categories: Category[];
|
||||
public currentTime: string;
|
||||
public transactionDate: string;
|
||||
|
||||
constructor(
|
||||
private app: AppComponent,
|
||||
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.app.setTitle(this.title)
|
||||
this.app.setBackEnabled(true);
|
||||
let d: Date, expense: boolean;
|
||||
if (this.currentTransaction) {
|
||||
d = new Date(this.currentTransaction.date);
|
||||
expense = this.currentTransaction.expense;
|
||||
} else {
|
||||
d = new Date();
|
||||
expense = true;
|
||||
}
|
||||
this.updateCategories(new MatRadioChange(undefined, expense));
|
||||
this.transactionDate = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||||
this.currentTime = d.toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (!changes.currentTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date(changes.currentTransaction.currentValue.date * 1000);
|
||||
this.transactionDate = d.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
this.currentTime = d.toLocaleTimeString(undefined, { hour: '2-digit', hour12: false, minute: '2-digit' });
|
||||
}
|
||||
|
||||
updateCategories(change: MatRadioChange) {
|
||||
this.twigsService.getCategories(this.budgetId)
|
||||
.then(newCategories => {
|
||||
this.categories = newCategories.filter(category => category.expense === change.value)
|
||||
})
|
||||
}
|
||||
|
||||
save(): void {
|
||||
let promise;
|
||||
this.currentTransaction.amount = decimalToInteger(String(this.currentTransaction.amount))
|
||||
this.currentTransaction.date = new Date();
|
||||
const dateParts = this.transactionDate.split('-');
|
||||
this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10));
|
||||
this.currentTransaction.date.setMonth(parseInt(dateParts[1], 10) - 1);
|
||||
this.currentTransaction.date.setDate(parseInt(dateParts[2], 10));
|
||||
const timeParts = this.currentTime.split(':');
|
||||
this.currentTransaction.date.setHours(parseInt(timeParts[0], 10));
|
||||
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
|
||||
if (this.create) {
|
||||
// This is a new transaction, save it
|
||||
promise = this.twigsService.createTransaction(
|
||||
this.currentTransaction.id,
|
||||
this.budgetId,
|
||||
this.currentTransaction.title,
|
||||
this.currentTransaction.description,
|
||||
this.currentTransaction.amount,
|
||||
this.currentTransaction.date,
|
||||
this.currentTransaction.expense,
|
||||
this.currentTransaction.categoryId,
|
||||
);
|
||||
} else {
|
||||
// This is an existing transaction, update it
|
||||
const updatedTransaction: Transaction = {
|
||||
...this.currentTransaction,
|
||||
}
|
||||
promise = this.twigsService.updateTransaction(
|
||||
this.currentTransaction.id,
|
||||
updatedTransaction
|
||||
);
|
||||
}
|
||||
|
||||
promise.then(() => {
|
||||
this.app.goBack();
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
this.twigsService.deleteTransaction(this.currentTransaction.id).then(() => {
|
||||
this.app.goBack();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<app-add-edit-transaction [budgetId]="budgetId" [title]="'Add Transaction'" [currentTransaction]="transaction" [create]="true"></app-add-edit-transaction>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue