WIP: Port over work from twigs-nextcloud

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2020-11-17 16:47:00 +00:00
parent 71a4d8e8a5
commit 1752c5d2d0
32 changed files with 1611 additions and 219 deletions

1
.env.development Normal file
View file

@ -0,0 +1 @@
VUE_APP_API_URL=https://3000code.brawner.home

View file

@ -1,24 +1,38 @@
# twigs-web
# Twigs
## Project setup
```
npm install
```
## Vue Migration Checklist
### Compiles and hot-reloads for development
```
npm run serve
```
_Could also be used as a testing checklist_
### Compiles and minifies for production
```
npm run build
```
- [ ] 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
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

160
package-lock.json generated
View file

@ -1739,6 +1739,16 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"cacache": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz",
@ -1765,6 +1775,34 @@
"unique-filename": "^1.1.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"find-cache-dir": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
@ -1786,6 +1824,25 @@
"path-exists": "^4.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -1850,6 +1907,16 @@
"minipass": "^3.1.1"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"terser-webpack-plugin": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
@ -1866,6 +1933,18 @@
"terser": "^4.6.12",
"webpack-sources": "^1.4.3"
}
},
"vue-loader-v16": {
"version": "npm:vue-loader@16.0.0-rc.1",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-rc.1.tgz",
"integrity": "sha512-yR+BS90EOXTNieasf8ce9J3TFCpm2DGqoqdbtiwQ33hon3FyIznLX7sKavAq1VmfBnOeV6It0Htg4aniv8ph1g==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
}
}
}
},
@ -11153,87 +11232,6 @@
}
}
},
"vue-loader-v16": {
"version": "npm:vue-loader@16.0.0-beta.8",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-beta.8.tgz",
"integrity": "sha512-oouKUQWWHbSihqSD7mhymGPX1OQ4hedzAHyvm8RdyHh6m3oIvoRF+NM45i/bhNOlo8jCnuJhaSUf/6oDjv978g==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"vue-router": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.6.tgz",

View file

@ -5,13 +5,13 @@
<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 <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong>We're sorry but Twigs doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

37
public/style.css Normal file
View file

@ -0,0 +1,37 @@
: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);
}

View file

@ -1,32 +1,21 @@
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
<div>
<RouterView />
</div>
</template>
<script>
export default {
components: {
}
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
.app-twigs {
flex-grow: 1;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
.app-twigs > div {
width: 100%;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1,58 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<div class="progress-bar">
<div class="progress" :class="status" :style="{ width: progress + '%' }" />
</div>
</template>
<script>
export default {
name: 'ProgressBar',
props: {
value: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 0,
},
invertColors: Boolean,
},
computed: {
progress: function() {
return Math.round((this.value / this.max) * 100)
},
status: function() {
if (this.progress <= 33) {
return this.invertColors ? 'danger' : 'good'
}
if (this.progress <= 66) {
return 'warn'
}
if (this.progress <= 100) {
return this.invertColors ? 'good' : 'danger'
}
return ''
},
},
}
</script>
<style scoped>
.progress-bar {
height: 0.5em;
background: #f1f1f1;
border-radius: 1em;
overflow: hidden;
}
.progress {
height: 100%;
width: 0%;
transition: width ease-in-out 0.5s;
}
.progress.good {
background: var(--good-color);
}
.progress.warn {
background: var(--warn-color);
}
.progress.danger {
background: var(--danger-color);
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<div class="twigs-home">
<h2>Welcome to Twigs!</h2>
<p>To get started, create a new budget or select one from the list to the left.</p>
</div>
</template>
<script>
export default {
name: 'TwigsHome',
components: {
},
computed: {
},
mounted() {
},
methods: {
},
}
</script>

View file

@ -0,0 +1,118 @@
<template>
<div>
<div v-if="budget" class="budget">
<div class="header">
<div class="header-info">
<h2>{{ budget.name }}</h2>
<p>{{ budget.description }}</p>
<h3
v-if="balance"
>Balance: {{ balance.toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</h3>
</div>
<div class="actions">
<button @click="addTransaction()">
<span class="icon-add" /> Add Transaction
</button>
<button @click="addCategory()">
<span class="icon-add" /> Add Category
</button>
<button @click="editBudget()">Edit</button>
<button @click="deleteBudget()">Delete</button>
</div>
</div>
<div class="budget-details">
<div class="card income">
<h3>Income</h3>
<CategoryList :budget-id="budget.id" :expense="false" />
</div>
<div class="card expenses">
<h3>Expenses</h3>
<CategoryList :budget-id="budget.id" :expense="true" />
</div>
<div class="card transactions">
<h3>Recent Transactions</h3>
<TransactionList :budget-id="budget.id" :limit="5" />
</div>
</div>
</div>
<div v-if="!budget">
<p>Select a budget from the list to the left to get started.</p>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex";
import CategoryList from "../category/CategoryList";
import TransactionList from "../transaction/TransactionList";
export default {
name: "BudgetDetails",
components: {
CategoryList,
TransactionList
},
computed: {
...mapState(["budgets", "currentBudget"]),
...mapGetters(["budget"]),
balance: function(state) {
if (!state.currentBudget) {
return 0;
}
return this.$store.getters.budgetBalance(state.currentBudget) / 100;
}
},
mounted() {
this.load();
},
methods: {
load() {
this.$store.dispatch("budgetDetailsViewed", this.$route.params.id);
},
addTransaction() {
this.$store.dispatch("addTransactionClicked");
},
addCategory() {
this.$store.dispatch("addCategoryClicked");
},
editBudget() {
this.$store.dispatch("editBudgetClicked", this.$route.params.id);
},
deleteBudget() {
this.$store.dispatch("deleteBudgetClicked", this.$route.params.id);
}
}
};
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.budget-details {
display: grid;
justify-content: space-between;
grid-gap: 0.5em;
grid-template-columns: repeat(2, 1fr);
padding: 0.5em;
}
.card {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
}
.income {
grid-column: 1;
}
.expenses {
grid-column: 2;
}
.transactions {
grid-column: 1;
}
</style>

View file

@ -0,0 +1,90 @@
<template>
<div>
<div v-if="!loading" class="add-edit-budget">
<h2>{{ budget.id ? 'Edit' : 'Add' }} Budget</h2>
<input v-model="budget.name"
type="text"
placeholder="Name"
title="Name">
<textarea v-model="budget.description" placeholder="Description" title="Description" />
<div class="sharing">
<h3>Sharing</h3>
<input v-model="user"
type="test"
placeholde="User"
title="User"
@keyup.enter="addPermission()">
<ul v-if="budget.users" class="sharing-users">
<li v-for="userPermission in budget.users" :key="userPermission.user">
<span v-if="userPermission.user">
{{ userPermission.user }}
</span>
<span v-if="userPermission.permission">
: {{ userPermission.permission }}
</span>
</li>
</ul>
</div>
<button @click="saveBudget()">
Save Budget
</button>
</div>
<div v-if="loading" class="icon-loading" />
</div>
</template>
<script>
export default {
name: 'BudgetForm',
components: {
},
props: {
budget: {
default: {},
type: () => {},
},
},
data: function() {
return {
saving: false,
user: undefined,
}
},
computed: {
loading: state => state.budget === undefined || state.saving,
},
methods: {
addPermission() {
const user = this.user
this.user = undefined
this.budget.users = this.budget.users.filter(u => u.user !== user)
this.budget.users.push({
user: user,
permission: 2,
})
},
saveBudget() {
this.saving = true
this.$store.dispatch('budgetFormSaveClicked', this.budget)
},
},
}
</script>
<style scoped>
.add-edit-budget > * {
display: block;
width: 100%;
max-width: 500px;
}
.radio-container {
display: flex;
align-items: center;
}
.radio-container label {
margin-right: 1em;
}
.icon-loading {
margin-top: 16px;
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<ul>
<li @click="newBudget()">New Budget</li>
<li v-for="budget in budgets" :key="budget.id">
<router-link :to="{ name: 'budgetDetails', params: { id: budget.id } }">{{ budget.name }}</router-link>
</li>
</ul>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "BudgetList",
components: {
},
computed: {
...mapGetters(["budgets"])
},
mounted() {
this.load();
},
methods: {
load: function() {
this.$store.dispatch("budgetListViewed");
},
newBudget: function() {
this.$store.dispatch("addBudgetClicked");
}
}
};
</script>

View file

@ -0,0 +1,20 @@
<template>
<BudgetForm :budget="budget" />
</template>
<script>
import { mapGetters } from 'vuex'
import BudgetForm from './BudgetForm'
export default {
name: 'EditBudget',
components: {
BudgetForm,
},
computed: {
...mapGetters(['budget']),
},
mounted() {
this.$store.dispatch('editBudgetViewed', this.$route.params.id)
},
}
</script>

View file

@ -0,0 +1,20 @@
<template>
<BudgetForm :budget="budget" />
</template>
<script>
import BudgetForm from './BudgetForm'
export default {
name: 'NewBudget',
components: {
BudgetForm,
},
data: function() {
return {
budget: {
users: [],
},
}
},
}
</script>

View file

@ -0,0 +1,66 @@
<template>
<div v-if="category" class="category-details">
<div class="header-info">
<h2>{{ category.title }}</h2>
<h3>
Balance: {{ balance.toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}
</h3>
<div class="actions">
<button @click="addTransaction()">
<span class="icon-add" /> Add Transaction
</button>
<button @click="editCategory()">
Edit
</button>
<button @click="deleteCategory()">
Delete
</button>
</div>
</div>
<h3>Transactions</h3>
<TransactionList :category-id="category.id" />
</div>
</template>
<script>
import { mapState } from 'vuex'
import TransactionList from '../transaction/TransactionList'
export default {
name: 'CategoryDetails',
components: {
TransactionList,
},
computed: {
...mapState(['categories', 'currentCategory']),
category: function(state) {
if (state.categories.length === 0 || !state.currentCategory) {
return false
}
return state.categories.find((category) => category.id === Number.parseInt(state.currentCategory))
},
balance: function(state) {
if (!state.currentCategory) {
return 0
}
return this.$store.getters.categoryBalance(state.currentCategory) / 100
},
},
mounted() {
this.load()
},
methods: {
load() {
this.$store.dispatch('categoryDetailsViewed', this.$route.params.id)
},
addTransaction() {
this.$store.dispatch('addTransactionClicked')
},
editCategory() {
this.$store.dispatch('editCategoryClicked', this.$route.params.id)
},
deleteCategory() {
this.$store.dispatch('deleteCategoryClicked', this.$route.params.id)
},
},
}
</script>

View file

@ -0,0 +1,91 @@
<template>
<div>
<div v-if="!loading" class="add-edit-category">
<h2>{{ category.id ? 'Edit' : 'Add' }} Category</h2>
<input v-model="category.title"
type="text"
placeholder="Name"
title="Name">
<input v-model.number="category.amount"
type="number"
placeholder="Amount"
title="Amount">
<div class="radio-container">
<input id="expense"
v-model="category.expense"
type="radio"
:value="true">
<label for="expense">Expense</label>
<input id="income"
v-model="category.expense"
type="radio"
:value="false">
<label for="income">Income</label>
</div>
<div class="row">
<input v-model="category.archived" type="checkbox"/>
<label>Archived</label>
</div>
<select v-model="category.budgetId" @change="updateCategories()">
<option disabled value>
Select a budget
</option>
<option v-for="budget in budgets" :key="budget.id" :value="budget.id">
{{ budget.name }}
</option>
</select>
<button @click="saveCategory()">
Save Category
</button>
</div>
<div v-if="loading" class="icon-loading" />
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'CategoryForm',
components: {
},
props: {
category: {
default: () => {},
type: Object,
},
},
data: function() {
return {
saving: false,
}
},
computed: {
...mapGetters(['budgets']),
loading: state => state.category === undefined || state.saving,
},
methods: {
saveCategory() {
this.saving = true
this.$store.dispatch('categoryFormSaveClicked', this.category)
},
},
}
</script>
<style scoped>
.add-edit-category > * {
display: block;
width: 100%;
max-width: 500px;
}
.radio-container {
display: flex;
align-items: center;
}
.radio-container label {
margin-right: 1em;
}
.icon-loading {
margin-top: 16px;
}
</style>

View file

@ -0,0 +1,85 @@
<template>
<ul>
<li v-for="category in filteredCategories" :key="category.id">
<a class="category-summary" @click="view(category.id)">
<div class="category-info">
<p class="category-name">{{ category.title }}</p>
<p class="category-balance">
{{ category.expense ? "Remaining" : "Pending" }}:
{{
(
categoryRemainingBalance(category) / 100
).toLocaleString(undefined, {
style: "currency",
currency: "USD"
})
}}
</p>
</div>
<ProgressBar
:max="category.amount"
:value="Math.abs(categoryBalance(category.id))"
:invert-colors="!category.expense" />
</a>
</li>
</ul>
</template>
<script>
import { mapGetters, mapState } from 'vuex'
import ProgressBar from '../ProgressBar'
export default {
name: 'CategoryList',
components: {
ProgressBar,
},
props: {
budgetId: {
default: 0,
type: Number,
},
expense: {
default: true,
type: Boolean,
},
},
computed: {
...mapState(['categories', 'currentCategory']),
...mapGetters(['categoryBalance', 'categoryRemainingBalance']),
filteredCategories: function(state) {
return state.categories.filter(
category => category.expense === this.expense && category.archived === false
)
},
},
methods: {
view: function(id) {
this.$store.dispatch('categoryClicked', id)
},
},
}
</script>
<style>
.category-summary {
padding: 0.5em;
height: 4em;
display: flex;
flex-direction: column;
justify-content: space-evenly;
cursor: pointer;
}
.category-summary * {
cursor: pointer;
}
.category-summary:hover {
background: var(--color-background-hover);
}
.category-summary .category-info {
display: flex;
justify-content: space-between;
padding-bottom: 0.5em;
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<CategoryForm :category="category" />
</template>
<script>
import { mapGetters } from 'vuex'
import CategoryForm from './CategoryForm'
export default {
name: 'EditCategory',
components: {
CategoryForm,
},
computed: {
...mapGetters(['category']),
},
mounted() {
this.$store.dispatch('editCategoryViewed', this.$route.params.id)
},
}
</script>

View file

@ -0,0 +1,21 @@
<template>
<CategoryForm :category="category" />
</template>
<script>
import CategoryForm from './CategoryForm'
export default {
name: 'NewCategory',
components: {
CategoryForm,
},
data: function() {
return {
category: {
expense: true,
budgetId: this.$store.state.currentBudget,
},
}
},
}
</script>

View file

@ -0,0 +1,20 @@
<template>
<TransactionForm :transaction="transaction" />
</template>
<script>
import { mapGetters } from 'vuex'
import TransactionForm from './TransactionForm'
export default {
name: 'EditTransaction',
components: {
TransactionForm,
},
computed: {
...mapGetters(['transaction']),
},
mounted() {
this.$store.dispatch('editTransactionViewed', this.$route.params.id)
},
}
</script>

View file

@ -0,0 +1,23 @@
<template>
<TransactionForm :transaction="transaction" />
</template>
<script>
import TransactionForm from './TransactionForm'
export default {
name: 'NewTransaction',
components: {
TransactionForm,
},
data: function() {
return {
transaction: {
date: new Date(),
expense: true,
budgetId: this.$store.state.currentBudget,
categoryId: this.$store.state.currentCategory,
},
}
},
}
</script>

View file

@ -0,0 +1,69 @@
<template>
<div v-if="transaction" class="transaction-details">
<h2>{{ transaction.title }}</h2>
<h3
:class="transaction.expense ? 'danger' : 'good'">
{{ (transaction.amount / 100).toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }} {{ transaction.expense ? 'Expense' : 'Income' }}
</h3>
<p class="transaction-info date">
{{ new Date(transaction.date).toLocaleDateString() }}
</p>
<p class="transaction-info description">
{{ transaction.description }}
</p>
<p v-if="category" class="transaction-info category">
Category: {{ category.title }}
</p>
<p v-if="budget" class="transaction-info budget">
Budget: {{ budget.name }}
</p>
<p class="transaction-info registered-by">
Registered By:
<!-- <UserBubble
:user="transaction.createdBy"
:display-name="transaction.createdBy" /> -->
{{ new Date(transaction.createdDate).toLocaleDateString() }}
</p>
<p v-if="transaction.updatedBy" class="transaction-info updated-by">
Updated By:
<!-- <UserBubble
:user="transaction.updatedBy"
:display-name="transaction.updatedBy" /> -->
{{ new Date(transaction.updatedDate).toLocaleDateString() }}
</p>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'TransactionDetails',
components: {
},
computed: {
...mapGetters(['transaction']),
category: function() {
const transaction = this.$store.getters.transaction
if (!transaction || !transaction.categoryId) {
return undefined
}
return this.$store.getters.categories.find(category => category.id === transaction.categoryId)
},
budget: function() {
const transaction = this.$store.getters.transaction
if (!transaction || !transaction.budgetId) {
return undefined
}
return this.$store.getters.budgets.find(budget => budget.id === transaction.budgetId)
},
},
mounted() {
this.load()
},
methods: {
load() {
this.$store.dispatch('transactionDetailsViewed', this.$route.params.id)
},
},
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<div>
<div v-if="!loading" class="add-edit-transaction">
<h2>{{ transaction.id ? "Edit" : "Add" }} Transaction</h2>
<input v-model="transaction.title" type="text" placeholder="Name" title="Name" />
<textarea v-model="transaction.description" placeholder="Description" title="Description" />
<input v-model.number="transaction.amount" type="number" placeholder="Amount" title="Amount" />
<!-- <DatetimePicker :value="transaction.date" type="datetime" /> -->
<div class="radio-container">
<input id="expense" v-model="transaction.expense" type="radio" :value="true" />
<label for="expense">Expense</label>
<input id="income" v-model="transaction.expense" type="radio" :value="false" />
<label for="income">Income</label>
</div>
<select v-model="transaction.budgetId" @change="updateCategories()">
<option disabled value>Select a budget</option>
<option v-for="budget in budgets" :key="budget.id" :value="budget.id">
{{ budget.name }}
</option>
</select>
<select v-model="transaction.categoryId">
<option disabled value>Select a category</option>
<option
v-for="category in filteredCategories"
:key="category.id"
:value="category.id"
>{{ category.title }}</option>
</select>
<button @click="saveTransaction()">Save Transaction</button>
</div>
<div v-if="loading" class="icon-loading" />
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "TransactionForm",
components: {},
props: {
transaction: {
default: () => {},
type: Object
}
},
data: function() {
return {
saving: false
};
},
computed: {
...mapGetters(["budgets"]),
filteredCategories: function(state) {
return this.$store.getters.categories.filter(function(category) {
return category.budgetId === state.transaction.budgetId && category.expense === state.transaction.expense;
});
},
loading: state => state.transaction === undefined || state.saving
},
mounted() {
this.updateCategories();
},
methods: {
updateCategories() {
if (!this.transaction) return;
this.$store.dispatch(
"addEditTransactionBudgetSelected",
this.transaction.budgetId
);
},
saveTransaction() {
this.saving = true;
this.$store.dispatch("transactionFormSaveClicked", this.transaction);
}
}
};
</script>
<style scoped>
.add-edit-transaction > * {
display: block;
width: 100%;
max-width: 500px;
}
.radio-container {
display: flex;
align-items: center;
}
.radio-container label {
margin-right: 1em;
}
.icon-loading {
margin-top: 16px;
}
</style>

View file

@ -0,0 +1,85 @@
<template>
<ul>
<li v-for="transaction in filteredTransactions" :key="transaction.id">
<a class="transaction" @click="view(transaction.id)">
<div class="transaction-details">
<p class="transaction-name">{{ transaction.title }}</p>
<p class="transaction-date">{{ new Date(transaction.date).toLocaleDateString() }}</p>
</div>
<p
class="transaction-amount"
:class="transaction.expense ? 'danger' : 'good'">{{ (transaction.amount / 100).toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</p>
</a>
</li>
</ul>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'TransactionList',
components: {},
props: {
budgetId: {
default: 0,
type: Number,
},
categoryId: {
default: 0,
type: Number,
},
limit: {
default: 0,
type: Number,
},
},
computed: {
...mapState(['transactions', 'currentTransaction']),
filteredTransactions: function(state) {
const transactions = state.transactions.filter(function(transaction) {
if (state.budgetId) {
return transaction.budgetId === state.budgetId
}
if (state.categoryId) {
return transaction.categoryId === state.categoryId
}
return false
})
if (this.limit !== 0) {
return transactions.slice(0, this.limit)
} else {
return transactions
}
},
},
methods: {
view: function(id) {
this.$store.dispatch('transactionClicked', id)
},
},
}
</script>
<style>
.transaction {
padding: 0.5em;
height: 4em;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.transaction * {
cursor: pointer;
}
.transaction:hover {
background: var(--color-background-hover);
}
.transaction-details {
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<div>
<div v-if="!loading" class="login">
<h2>Login</h2>
<input v-model="username"
type="text"
placeholder="Username or Email"
title="Username or Email">
<input v-model="password"
type="password"
placeholder="Password"
title="Password">
<button @click="login()">
Login
</button>
</div>
<div v-if="loading" class="icon-loading" />
</div>
</template>
<script>
export default {
name: 'Login',
components: {
},
props: [
'username',
'password'
],
data: function() {
return {
loading: false,
}
},
methods: {
login() {
this.loading = true
this.$store.dispatch('loginSubmitClicked', {
username: this.username,
password: this.password
})
},
},
}
</script>
<style scoped>
.login > * {
display: block;
width: 100%;
max-width: 500px;
}
.icon-loading {
margin-top: 16px;
}
</style>

View file

View file

View file

@ -1,23 +1,81 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import TwigsHome from '../components/TwigsHome'
import Login from '../components/user/Login'
import NewBudget from '../components/budget/NewBudget'
import EditBudget from '../components/budget/EditBudget'
import BudgetDetails from '../components/budget/BudgetDetails'
import BudgetList from '../components/budget/BudgetList'
import NewCategory from '../components/category/NewCategory'
import EditCategory from '../components/category/EditCategory'
import CategoryDetails from '../components/category/CategoryDetails'
import NewTransaction from '../components/transaction/NewTransaction'
import EditTransaction from '../components/transaction/EditTransaction'
import TransactionDetails from '../components/transaction/TransactionDetails'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home
name: 'home',
component: TwigsHome,
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
path: '/login',
name: 'login',
component: Login,
},
{
path: '/budgets',
name: 'budgets',
component: BudgetList,
},
{
path: '/budgets/new',
name: 'newBudget',
component: NewBudget,
},
{
path: '/budgets/:id',
name: 'budgetDetails',
component: BudgetDetails,
},
{
path: '/budgets/:id/edit',
name: 'editBudget',
component: EditBudget,
},
{
path: '/categories/new',
name: 'newCategory',
component: NewCategory,
},
{
path: '/categories/:id',
name: 'categoryDetails',
component: CategoryDetails,
},
{
path: '/categories/:id/edit',
name: 'editCategory',
component: EditCategory,
},
{
path: '/transactions/new',
name: 'newTransaction',
component: NewTransaction,
},
{
path: '/transactions/:id',
name: 'transactionDetails',
component: TransactionDetails,
},
{
path: '/transactions/:id/edit',
name: 'editTransaction',
component: EditTransaction,
},
]
const router = new VueRouter({

View file

@ -1,15 +1,399 @@
/* eslint-disable no-unused-vars */
import Vue from 'vue'
import Vuex from 'vuex'
import router from '../router'
Vue.use(Vuex)
const API_URL = process.env.VUE_APP_API_URL;
function authenticatedHeaders(authentication) {
let authorization;
if (authentication !== undefined) {
authorization = authentication
} else {
const authCookie = document.cookie.split(';')
.map(cookie => cookie.trim())
.find(cookie => cookie.indexOf('authorization') === 0);
if (authCookie) {
authorization = authCookie.slice(14)
}
}
const headers = new Headers({
'Content-Type': 'application/json'
});
if (authorization) {
headers.append('Authorization', authorization);
}
return headers;
}
export default new Vuex.Store({
state: {
user: undefined,
budgets: [],
budgetBalances: {},
currentBudget: 0,
categories: [],
categoryBalances: {},
currentCategory: 0,
transactions: [],
currentTransaction: 0,
},
mutations: {
getters: {
user: (state) => state.user,
budgets: (state) => state.budgets,
budget: (state) => state.budgets.find(budget => budget.id === state.currentBudget),
budgetBalance: (state) => (id) => state.budgetBalances[id],
categories: (state) => state.categories,
category: (state) => state.categories.find(category => category.id === state.currentCategory),
categoryBalance: (state) => (categoryId) => {
return state.categoryBalances[categoryId] || 0
},
categoryRemainingBalance: (state, getters) => (category) => {
const modifier = category.expense ? -1 : 1
return category.amount - (getters.categoryBalance(category.id) * modifier)
},
transactions: (state) => state.transactions,
transaction: (state) => state.transactions.find(transaction => transaction.id === state.currentTransaction),
},
actions: {
loginClicked() {
router.push({ name: 'login' })
},
loginSubmitClicked({ commit }, credentials) {
const auth = `Basic ${btoa(credentials.username + ':' + credentials.password)}`.trim();
get('users/me')
.then(user => {
document.cookie = `authorization=${auth}`;
commit('setUser', user)
router.push({ name: 'budgets' })
})
},
addBudgetClicked({ commit }) {
router.push({ name: 'newBudget' })
},
budgetListViewed({ commit }) {
get('budgets')
.then(budgets => {
commit('setBudgets', budgets)
budgets.forEach(budget => {
get(`budgets/${budget.id}/balance`)
.then(balance => {
commit({
type: 'setBudgetBalance',
...balance,
})
})
})
})
},
budgetClicked({ commit }, budgetId) {
router.push({ name: 'budgetDetails', params: { id: budgetId } })
},
editBudgetViewed({ commit, state, getters }, budgetId) {
commit('setCurrentBudget', budgetId)
if (budgetId !== undefined && getters.budget === undefined) {
get(`${API_URL}/budgets/${budgetId}`)
.then(budgets => {
commit('setBudgets', [budgets])
})
}
},
budgetFormSaveClicked({ commit }, budget) {
let request
if (budget.id) {
request = put(`budgets/${budget.id}`, budget)
} else {
request = post(`budgets`, budget)
}
request.then(budget => {
commit('addBudget', budget)
router.push({ name: 'budgetDetails', params: { id: budget.id } })
})
},
budgetDetailsViewed({ commit, state, getters }, budgetId) {
commit('setCurrentBudget', budgetId)
if (budgetId !== undefined && getters.budget === undefined) {
fetch(`${API_URL}/budgets`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(budgets => {
commit('setBudgets', budgets)
})
}
commit('setCategories', [])
commit('setTransactions', [])
commit('setCurrentCategory', undefined)
fetch(`${API_URL}/categories?budgetIds=${budgetId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(categories => {
commit('setCategories', categories)
categories.forEach(category => {
fetch(`${API_URL}/categories/${category.id}/balance`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(balance => {
commit({
type: 'setCategoryBalance',
...balance,
})
})
})
})
fetch(`${API_URL}/transactions?budgetId=${budgetId}?count=10`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(transactions => commit('setTransactions', transactions))
},
editBudgetClicked({ commit }, budgetId) {
router.push({ name: 'editBudget', params: { id: budgetId } })
},
deleteBudgetClicked({ commit }, budgetId) {
fetch(`${API_URL}/budgets/${budgetId}`, {
method: 'DELETE',
headers: authenticatedHeaders()
})
.then(() => {
commit('deleteBudget', budgetId)
router.push({ name: 'home' })
})
},
categoryClicked({ commit }, categoryId) {
router.push({ name: 'categoryDetails', params: { id: categoryId } })
},
addCategoryClicked({ commit }) {
router.push({ name: 'newCategory' })
},
editCategoryClicked({ commit }, categoryId) {
router.push({ name: 'editCategory', params: { id: categoryId } })
},
editCategoryViewed({ commit, state, getters }, categoryId) {
commit('setCurrentCategory', categoryId)
if (categoryId !== undefined && getters.category === undefined) {
fetch(`${API_URL}/categories/${categoryId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(category => {
commit('setCategories', [category])
})
}
},
categoryDetailsViewed({ commit, state }, categoryId) {
commit('setCurrentCategory', categoryId)
if (state.categories.length === 0) {
fetch(`${API_URL}/categories/${categoryId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(category => {
commit('setCategories', [category])
})
}
fetch(`${API_URL}/transactions?categoryId=${categoryId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(transactions => commit('setTransactions', transactions))
},
categoryFormSaveClicked({ commit }, category) {
let request
if (category.id) {
request = put(`categories/${category.id}`, category)
} else {
request = post('categories', category)
}
request.then(category => {
commit('addCategory', category)
router.push({ name: 'categoryDetails', params: { id: category.id } })
})
},
deleteCategoryClicked({ commit, state }, categoryId) {
fetch(`${API_URL}/categories/${categoryId}`, {
method: 'DELETE',
headers: authenticatedHeaders(),
})
.then(() => {
commit('setCurrentCategory', undefined)
commit('deleteCategory', categoryId)
router.push({ name: 'budgetDetails', params: { id: state.currentBudget } })
})
},
addTransactionClicked({ commit }) {
router.push({ name: 'newTransaction' })
},
editTransactionViewed({ commit, state, getters }, transactionId) {
commit('setCurrentTransaction', transactionId)
if (transactionId !== undefined && getters.transaction === undefined) {
fetch(`${API_URL}/transactions/${transactionId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(transactions => {
commit('setTransactions', [transactions])
})
}
},
addEditTransactionBudgetSelected({ commit, state }, budgetId) {
commit('setCategories', [])
if (!budgetId) return
fetch(`${API_URL}/categories?budgetId=${budgetId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(categories => {
commit('setCategories', categories)
})
},
transactionFormSaveClicked({ commit }, transaction) {
let request
if (transaction.id) {
request = put(`transactions/${transaction.id}`, transaction)
} else {
request = post('transactions', transaction)
}
request.then(res => res.json())
.then(transaction => {
commit('addTransaction', transaction)
router.push({ name: 'transactionDetails', params: { id: transaction.id } })
})
},
transactionClicked({ commit }, transactionId) {
router.push({ name: 'transactionDetails', params: { id: transactionId } })
},
transactionDetailsViewed({ commit, state }, transactionId) {
commit('setCurrentTransaction', transactionId)
if (state.transactions.length === 0) {
fetch(`${API_URL}/transactions/${transactionId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(transaction => {
commit('setTransactions', [transaction])
if (state.categories.length === 0) {
fetch(`${API_URL}/categories?budgetId=${transaction.budgetId}`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(categories => {
commit('setCategories', categories)
categories.forEach(category => {
fetch(`${API_URL}/categories/${category.id}/balance`, { headers: authenticatedHeaders() })
.then(res => res.json())
.then(balance => {
commit({
type: 'setCategoryBalance',
...balance,
})
})
})
})
}
})
}
},
},
modules: {
mutations: {
setUser(state, user) {
state.user = user
},
addBudget(state, budget) {
state.budgets = [
...state.budgets.filter(b => b.id !== budget.id),
budget,
]
},
setCurrentBudget(state, budgetId) {
state.currentBudget = Number.parseInt(budgetId)
},
setBudgetBalance(state, data) {
state.budgetBalances = {
...state.budgetBalances,
[data.budgetId]: data.balance,
}
},
setBudgets(state, budgets) {
state.budgets = budgets
},
deleteBudget(state, budget) {
state.budgets = [
...state.budgets.filter(b => b.id !== budget.id),
]
},
addCategory(state, category) {
state.categories = [
...state.categories.filter(c => c.id !== category.id),
category,
]
},
setCurrentCategory(state, categoryId) {
state.currentCategory = Number.parseInt(categoryId)
},
setCategories(state, data) {
state.categories = data
},
setCategoryBalance(state, data) {
state.categoryBalances = {
...state.categoryBalances,
[data.id]: data.balance,
}
},
deleteCategory(state, category) {
state.categories = [
...state.categories.filter(c => c.id !== category.id),
]
},
addTransaction(state, transaction) {
state.transactions = [
...state.transactions.filter(t => t.id !== transaction.id),
transaction,
]
},
setTransactions(state, data) {
state.transactions = data
},
setCurrentTransaction(state, transactionId) {
state.currentTransaction = Number.parseInt(transactionId)
},
}
})
function get(path) {
return fetch(`${API_URL}/${path}`, { headers: authenticatedHeaders() })
.then(res => {
if (res.ok) {
return res.json()
} else {
throw Error(res.statusText)
}
})
}
function post(path, body) {
return fetch(`${API_URL}/${path}`, {
method: 'POST',
headers: authenticatedHeaders(),
body: JSON.stringify(body)
})
.then(res => {
if (res.ok) {
return res.json()
} else {
throw Error(res.statusText)
}
})
}
function put(path, body) {
return fetch(`${API_URL}/${path}`, {
method: 'PUT',
headers: authenticatedHeaders(),
body: JSON.stringify(body)
})
.then(res => {
if (res.ok) {
return res.json()
} else {
throw Error(res.statusText)
}
})
}
function del(path) {
return fetch(`${API_URL}/${path}`, {
method: 'DELETE',
headers: authenticatedHeaders()
})
.then(res => {
if (res.ok) {
return res.json()
} else {
throw Error(res.statusText)
}
})
}

View file

@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View file

@ -1,18 +0,0 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'Home',
components: {
HelloWorld
}
}
</script>