added tags, basic charts and import/export
Signed-off-by: Pascal Syma <pascal@syma.dev>
This commit is contained in:
Generated
+197
-568
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -20,7 +20,8 @@
|
|||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"popper.js": "^1.16.1",
|
"popper.js": "^1.16.1",
|
||||||
"vue": "^2.5.2",
|
"vue": "^2.5.2",
|
||||||
"vue-enums": "^1.0.0"
|
"vue-enums": "^1.0.0",
|
||||||
|
"vue-graph": "^0.8.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^7.1.2",
|
"autoprefixer": "^7.1.2",
|
||||||
|
|||||||
+336
-22
@@ -1,25 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app" class="container">
|
<div id="app" class="container">
|
||||||
<b-row class="results">
|
<b-row class="results">
|
||||||
<b-col sm="12" offset-md="1" md="10">
|
<b-col>
|
||||||
<b-card header="Result" title="Analysis">
|
<b-card title="Analysis" no-body>
|
||||||
<b-card-group class="text-center">
|
<b-card-header header-text-variant="white" class="container-fluid">
|
||||||
<b-card header="Yearly">
|
<b-row style="display: table">
|
||||||
<p>Income: {{ calculated.yearly.in | currency }}</p>
|
<b-col md="8" lg="10" style="display: table-cell; vertical-align: middle">Result</b-col>
|
||||||
<p>Expenses: {{ calculated.yearly.out | currency }}</p>
|
<b-col md="2" class="float-right align-self-center" style="display: inline-table">
|
||||||
<p>Profit: <span :class="calculated.yearly.sum < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum | currency }}</span></p>
|
<b-button-group size="sm">
|
||||||
</b-card>
|
<b-button v-b-modal.modalImport variant="info"><b-icon-upload /> Import</b-button>
|
||||||
<b-card header="Monthly">
|
<b-button id="exportButton" @click="exportData" variant="success"><b-icon-download /> Export</b-button>
|
||||||
<p>Income: {{ calculated.yearly.in/12 | currency }}</p>
|
<b-tooltip ref="exportTooltip" target="exportButton" disabled>Copied to clipboard!</b-tooltip>
|
||||||
<p>Expenses: {{ calculated.yearly.out/12 | currency }}</p>
|
<b-button @click="askReset" variant="danger"><b-icon-trash /> Reset</b-button>
|
||||||
<p>Profit: <span :class="calculated.yearly.sum/12 < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum/12 | currency }}</span></p>
|
</b-button-group>
|
||||||
</b-card>
|
</b-col>
|
||||||
<b-card header="Daily">
|
</b-row>
|
||||||
<p>Income: {{ calculated.yearly.in/365 | currency }}</p>
|
</b-card-header>
|
||||||
<p>Expenses: {{ calculated.yearly.out/365 | currency }}</p>
|
<b-card-body>
|
||||||
<p>Profit: <span :class="calculated.yearly.sum/365 < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum/365 | currency }}</span></p>
|
<b-row v-if="calculated.in.data.length + calculated.out.data.length > 1">
|
||||||
</b-card>
|
<b-col md="6"
|
||||||
</b-card-group>
|
v-for="key in ['in', 'out']"
|
||||||
|
:key="key">
|
||||||
|
<b-card
|
||||||
|
:header="calculated[key].title"
|
||||||
|
bg-variant="secondary">
|
||||||
|
<graph-pie
|
||||||
|
:width="NaN"
|
||||||
|
:height="400"
|
||||||
|
:padding-top="100"
|
||||||
|
:padding-bottom="100"
|
||||||
|
:padding-left="100"
|
||||||
|
:padding-right="100"
|
||||||
|
:names="calculated[key].labels"
|
||||||
|
:values="calculated[key].data"
|
||||||
|
:show-text-type="'outside'"
|
||||||
|
:active-event="'click'"
|
||||||
|
:data-format="dataFormat"
|
||||||
|
:styles="pieStyle">
|
||||||
|
<legends :names="calculated[key].labels"></legends>
|
||||||
|
<tooltip :names="calculated[key].labels"></tooltip></graph-pie>
|
||||||
|
</b-card>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
<b-row v-if="calculated.inTag.data.length + calculated.outTag.data.length > 1">
|
||||||
|
<b-col md="6"
|
||||||
|
v-for="key in ['inTag', 'outTag']"
|
||||||
|
:key="key">
|
||||||
|
|
||||||
|
<b-card
|
||||||
|
:header="calculated[key].title"
|
||||||
|
bg-variant="secondary">
|
||||||
|
<graph-pie
|
||||||
|
:width="NaN"
|
||||||
|
:height="400"
|
||||||
|
:padding-top="100"
|
||||||
|
:padding-bottom="100"
|
||||||
|
:padding-left="100"
|
||||||
|
:padding-right="100"
|
||||||
|
:names="calculated[key].labels"
|
||||||
|
:values="calculated[key].data"
|
||||||
|
:show-text-type="'outside'"
|
||||||
|
:active-event="'click'"
|
||||||
|
:data-format="dataFormat"
|
||||||
|
:styles="pieStyle">
|
||||||
|
<legends :names="calculated[key].labels"></legends>
|
||||||
|
<tooltip :names="calculated[key].labels"></tooltip></graph-pie>
|
||||||
|
</b-card>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
<b-card-group class="text-center">
|
||||||
|
<b-card header="Yearly">
|
||||||
|
<p>Income: {{ calculated.yearly.in | currency }}</p>
|
||||||
|
<p>Expenses: {{ calculated.yearly.out | currency }}</p>
|
||||||
|
<p>Profit: <span :class="calculated.yearly.sum < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum | currency }}</span></p>
|
||||||
|
</b-card>
|
||||||
|
<b-card header="Monthly">
|
||||||
|
<p>Income: {{ calculated.yearly.in/12 | currency }}</p>
|
||||||
|
<p>Expenses: {{ calculated.yearly.out/12 | currency }}</p>
|
||||||
|
<p>Profit: <span :class="calculated.yearly.sum/12 < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum/12 | currency }}</span></p>
|
||||||
|
</b-card>
|
||||||
|
<b-card header="Daily">
|
||||||
|
<p>Income: {{ calculated.yearly.in/365 | currency }}</p>
|
||||||
|
<p>Expenses: {{ calculated.yearly.out/365 | currency }}</p>
|
||||||
|
<p>Profit: <span :class="calculated.yearly.sum/365 < 0 ? 'text-danger' : 'text-success'">{{ calculated.yearly.sum/365 | currency }}</span></p>
|
||||||
|
</b-card>
|
||||||
|
</b-card-group>
|
||||||
|
</b-card-body>
|
||||||
</b-card>
|
</b-card>
|
||||||
</b-col>
|
</b-col>
|
||||||
</b-row>
|
</b-row>
|
||||||
@@ -27,6 +93,25 @@
|
|||||||
<Input title="Income" color="success" :entries="income" />
|
<Input title="Income" color="success" :entries="income" />
|
||||||
<Input title="Expenses" color="danger" :entries="expenses" />
|
<Input title="Expenses" color="danger" :entries="expenses" />
|
||||||
</b-row>
|
</b-row>
|
||||||
|
<b-modal id="modalImport" title="Import" @ok="importData" @show="resetImport" @hidden="resetImport">
|
||||||
|
<form ref="import" @submit.stop.prevent="importData">
|
||||||
|
<b-form-group
|
||||||
|
label="Data: "
|
||||||
|
label-for="data-input"
|
||||||
|
invalid-feedback="Correct data is required"
|
||||||
|
:state="importDataState"
|
||||||
|
>
|
||||||
|
<b-form-textarea
|
||||||
|
id="data-input"
|
||||||
|
v-model="importdata"
|
||||||
|
:state="importDataState"
|
||||||
|
rows="5"
|
||||||
|
no-resize
|
||||||
|
required
|
||||||
|
></b-form-textarea>
|
||||||
|
</b-form-group>
|
||||||
|
</form>
|
||||||
|
</b-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,8 +133,35 @@ export default {
|
|||||||
in: 0,
|
in: 0,
|
||||||
out: 0,
|
out: 0,
|
||||||
sum: 0
|
sum: 0
|
||||||
|
},
|
||||||
|
in: {
|
||||||
|
title: 'Income',
|
||||||
|
labels: [],
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
out: {
|
||||||
|
title: 'Expenses',
|
||||||
|
labels: [],
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
inTag: {
|
||||||
|
title: 'Income Tags',
|
||||||
|
labels: [],
|
||||||
|
data: []
|
||||||
|
},
|
||||||
|
outTag: {
|
||||||
|
title: 'Expenses Tags',
|
||||||
|
labels: [],
|
||||||
|
data: []
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
pieStyle: {
|
||||||
|
backgroundColor: '#ffffff00',
|
||||||
|
pieOuterFontColor: '#ccc',
|
||||||
|
legendFontColor: '#ccc'
|
||||||
|
},
|
||||||
|
importdata: '',
|
||||||
|
importDataState: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
@@ -60,6 +172,8 @@ export default {
|
|||||||
if (storageObj.expenses && storageObj.income) {
|
if (storageObj.expenses && storageObj.income) {
|
||||||
this.$set(this, 'expenses', storageObj.expenses)
|
this.$set(this, 'expenses', storageObj.expenses)
|
||||||
this.$set(this, 'income', storageObj.income)
|
this.$set(this, 'income', storageObj.income)
|
||||||
|
this.income.forEach(i => this.$set(i, '_showDetails', false))
|
||||||
|
this.expenses.forEach(i => this.$set(i, '_showDetails', false))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
@@ -87,13 +201,213 @@ export default {
|
|||||||
out: 0,
|
out: 0,
|
||||||
sum: 0
|
sum: 0
|
||||||
}
|
}
|
||||||
|
let inLabel = []
|
||||||
|
let inData = []
|
||||||
|
let outLabel = []
|
||||||
|
let outData = []
|
||||||
|
let inTagLabel = ['Without Tags']
|
||||||
|
let inTagData = [0]
|
||||||
|
let outTagLabel = ['Without Tags']
|
||||||
|
let outTagData = [0]
|
||||||
|
|
||||||
calc.in = [...this.income.map(i => Frequency.byId(i.frequency).interval * parseFloat(i.amount)), ...this.income.filter(i => i.frequency === 0).map(i => parseFloat(i.amount))].reduce((c, v) => c + v, 0)
|
const inList = [...this.income.map(i => {
|
||||||
calc.out = [...this.expenses.map(i => Frequency.byId(i.frequency).interval * parseFloat(i.amount)), ...this.expenses.filter(i => i.frequency === 0).map(i => parseFloat(i.amount))].reduce((c, v) => c + v, 0)
|
return {
|
||||||
|
name: i.name,
|
||||||
|
tags: i.tags,
|
||||||
|
amount: Frequency.byId(i.frequency).interval * parseFloat(i.amount)
|
||||||
|
}
|
||||||
|
}), ...this.income.filter(i => i.frequency === 0).map(i => {
|
||||||
|
return {
|
||||||
|
name: i.name,
|
||||||
|
tags: i.tags,
|
||||||
|
amount: parseFloat(i.amount)
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
const outList = [...this.expenses.map(i => {
|
||||||
|
return {
|
||||||
|
name: i.name,
|
||||||
|
tags: i.tags,
|
||||||
|
amount: Frequency.byId(i.frequency).interval * parseFloat(i.amount)
|
||||||
|
}
|
||||||
|
}), ...this.expenses.filter(i => i.frequency === 0).map(i => {
|
||||||
|
return {
|
||||||
|
name: i.name,
|
||||||
|
tags: i.tags,
|
||||||
|
amount: parseFloat(i.amount)
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
calc.in = inList.reduce((c, v) => c + v.amount, 0)
|
||||||
|
calc.out = outList.reduce((c, v) => c + v.amount, 0)
|
||||||
|
|
||||||
calc.sum = calc.in - calc.out
|
calc.sum = calc.in - calc.out
|
||||||
|
|
||||||
|
if (calc.sum < 0) {
|
||||||
|
inList.push({
|
||||||
|
name: 'Loss',
|
||||||
|
amount: -calc.sum,
|
||||||
|
tags: ['Loss']
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
outList.push({
|
||||||
|
name: 'Profit',
|
||||||
|
amount: calc.sum,
|
||||||
|
tags: ['Profit']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inList.forEach(e => {
|
||||||
|
inLabel.push(e.name)
|
||||||
|
inData.push(e.amount)
|
||||||
|
|
||||||
|
if (!e.tags) {
|
||||||
|
inTagData[0] += e.amount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: maybe consider using only the first tag, else the total will change because elements get included multiple times
|
||||||
|
e.tags.forEach(t => {
|
||||||
|
let index = inTagLabel.indexOf(t)
|
||||||
|
if (index === -1) {
|
||||||
|
inTagLabel.push(t)
|
||||||
|
inTagData.push(e.amount)
|
||||||
|
} else {
|
||||||
|
inTagData[index] += e.amount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
outList.forEach(e => {
|
||||||
|
outLabel.push(e.name)
|
||||||
|
outData.push(e.amount)
|
||||||
|
|
||||||
|
if (!e.tags) {
|
||||||
|
outTagData[0] += e.amount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.tags.forEach(t => {
|
||||||
|
let index = outTagLabel.indexOf(t)
|
||||||
|
if (index === -1) {
|
||||||
|
outTagLabel.push(t)
|
||||||
|
outTagData.push(e.amount)
|
||||||
|
} else {
|
||||||
|
outTagData[index] += e.amount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (outTagData[0] === 0) {
|
||||||
|
outTagData.shift()
|
||||||
|
outTagLabel.shift()
|
||||||
|
}
|
||||||
|
if (inTagData[0] === 0) {
|
||||||
|
inTagData.shift()
|
||||||
|
inTagLabel.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort asc
|
||||||
|
const sort = (label, data) => {
|
||||||
|
let merge = label.map((e, i) => {
|
||||||
|
return {
|
||||||
|
label: e,
|
||||||
|
data: data[i]
|
||||||
|
}
|
||||||
|
}).sort((a, b) => b.data - a.data)
|
||||||
|
|
||||||
|
label = merge.map(e => e.label)
|
||||||
|
data = merge.map(e => e.data)
|
||||||
|
|
||||||
|
return [label, data]
|
||||||
|
}
|
||||||
|
|
||||||
|
[inTagLabel, inTagData] = sort(inTagLabel, inTagData);
|
||||||
|
[outTagLabel, outTagData] = sort(outTagLabel, outTagData);
|
||||||
|
[inLabel, inData] = sort(inLabel, inData);
|
||||||
|
[outLabel, outData] = sort(outLabel, outData)
|
||||||
|
|
||||||
this.$set(this.calculated, 'yearly', calc)
|
this.$set(this.calculated, 'yearly', calc)
|
||||||
|
this.$set(this.calculated.inTag, 'labels', inTagLabel)
|
||||||
|
this.$set(this.calculated.inTag, 'data', inTagData)
|
||||||
|
this.$set(this.calculated.outTag, 'labels', outTagLabel)
|
||||||
|
this.$set(this.calculated.outTag, 'data', outTagData)
|
||||||
|
|
||||||
|
this.$set(this.calculated.in, 'labels', inLabel)
|
||||||
|
this.$set(this.calculated.in, 'data', inData)
|
||||||
|
this.$set(this.calculated.out, 'labels', outLabel)
|
||||||
|
this.$set(this.calculated.out, 'data', outData)
|
||||||
|
},
|
||||||
|
dataFormat (a, b) {
|
||||||
|
if (b) return this.$options.filters.currency(b)
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
askReset () {
|
||||||
|
// ask before deletion
|
||||||
|
this.$bvModal.msgBoxConfirm(['Please confirm that you want to reset.', 'All data will be lost forever! (A long time!)'].map(e => this.$createElement('p', [e])), {
|
||||||
|
title: 'Are you sure?',
|
||||||
|
size: 'md',
|
||||||
|
buttonSize: 'sm',
|
||||||
|
okVariant: 'danger',
|
||||||
|
okTitle: 'Yes',
|
||||||
|
cancelTitle: 'No',
|
||||||
|
footerClass: 'p-2',
|
||||||
|
hideHeaderClose: false,
|
||||||
|
centered: true
|
||||||
|
})
|
||||||
|
.then(confirmedDelete => {
|
||||||
|
if (!confirmedDelete) return
|
||||||
|
|
||||||
|
this.$set(this, 'expenses', [])
|
||||||
|
this.$set(this, 'income', [])
|
||||||
|
this.calculate()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resetImport () {
|
||||||
|
this.importdata = ''
|
||||||
|
this.importDataState = null
|
||||||
|
},
|
||||||
|
importData (bvModalEvt) {
|
||||||
|
let valid = this.$refs.import.checkValidity()
|
||||||
|
let obj
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(atob(this.importdata))
|
||||||
|
if (!obj.expenses || !obj.income) {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.importDataState = valid
|
||||||
|
if (bvModalEvt) bvModalEvt.preventDefault()
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
this.$set(this, 'expenses', obj.expenses)
|
||||||
|
this.$set(this, 'income', obj.income)
|
||||||
|
this.income.forEach(i => this.$set(i, '_showDetails', false))
|
||||||
|
this.expenses.forEach(i => this.$set(i, '_showDetails', false))
|
||||||
|
this.calculate()
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$bvModal.hide('modalImport')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportData () {
|
||||||
|
const storage = localStorage.getItem('expenses')
|
||||||
|
if (!storage) return
|
||||||
|
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = btoa(storage)
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.all = 'position: absolute; left: -9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
|
||||||
|
this.$refs.exportTooltip.$emit('open')
|
||||||
|
|
||||||
|
setTimeout(() => this.$refs.exportTooltip.$emit('close'), 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
{{ $enums.Frequency.byId(data.item.frequency).name }}
|
{{ $enums.Frequency.byId(data.item.frequency).name }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell(tags)="data">
|
||||||
|
{{ (data.item.tags ? data.item.tags.length : 0) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #row-details="row">
|
<template #row-details="row">
|
||||||
<b-card>
|
<b-card>
|
||||||
<b-form @submit.prevent="save(row)">
|
<b-form @submit.prevent="save(row)">
|
||||||
@@ -82,6 +86,17 @@
|
|||||||
required></b-form-select>
|
required></b-form-select>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group
|
||||||
|
id="tags-group"
|
||||||
|
description="Enter new tags separated by comma or semicolon"
|
||||||
|
label="Tags:"
|
||||||
|
label-for="tags">
|
||||||
|
<b-form-tags
|
||||||
|
id="tags"
|
||||||
|
separator=",;"
|
||||||
|
v-model="editingCache.tags"></b-form-tags>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
<b-button type="submit" variant="success"><b-icon-check /> Save</b-button>
|
<b-button type="submit" variant="success"><b-icon-check /> Save</b-button>
|
||||||
</b-form>
|
</b-form>
|
||||||
</b-card>
|
</b-card>
|
||||||
@@ -116,6 +131,7 @@ export default {
|
|||||||
'name',
|
'name',
|
||||||
'amount',
|
'amount',
|
||||||
'frequency',
|
'frequency',
|
||||||
|
'tags',
|
||||||
{
|
{
|
||||||
key: 'options',
|
key: 'options',
|
||||||
label: ''
|
label: ''
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import enums from 'vue-enums'
|
import enums from 'vue-enums'
|
||||||
|
import VueGraph from 'vue-graph'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
|
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ import 'bootswatch/dist/darkly/bootstrap.min.css'
|
|||||||
// import 'bootstrap/dist/css/bootstrap.min.css'
|
// import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||||
|
|
||||||
|
Vue.use(VueGraph)
|
||||||
|
|
||||||
Vue.use(enums, {namespace: '$enums'})
|
Vue.use(enums, {namespace: '$enums'})
|
||||||
|
|
||||||
// Make BootstrapVue available throughout your project
|
// Make BootstrapVue available throughout your project
|
||||||
|
|||||||
Reference in New Issue
Block a user