added tags, basic charts and import/export

Signed-off-by: Pascal Syma <pascal@syma.dev>
This commit is contained in:
2021-05-15 17:24:48 +02:00
parent 1a7d23ad57
commit 0a2c771b7b
5 changed files with 554 additions and 591 deletions
+336 -22
View File
@@ -1,25 +1,91 @@
<template>
<div id="app" class="container">
<b-row class="results">
<b-col sm="12" offset-md="1" md="10">
<b-card header="Result" title="Analysis">
<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-col>
<b-card title="Analysis" no-body>
<b-card-header header-text-variant="white" class="container-fluid">
<b-row style="display: table">
<b-col md="8" lg="10" style="display: table-cell; vertical-align: middle">Result</b-col>
<b-col md="2" class="float-right align-self-center" style="display: inline-table">
<b-button-group size="sm">
<b-button v-b-modal.modalImport variant="info"><b-icon-upload /> Import</b-button>
<b-button id="exportButton" @click="exportData" variant="success"><b-icon-download /> Export</b-button>
<b-tooltip ref="exportTooltip" target="exportButton" disabled>Copied to clipboard!</b-tooltip>
<b-button @click="askReset" variant="danger"><b-icon-trash /> Reset</b-button>
</b-button-group>
</b-col>
</b-row>
</b-card-header>
<b-card-body>
<b-row v-if="calculated.in.data.length + calculated.out.data.length > 1">
<b-col md="6"
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-col>
</b-row>
@@ -27,6 +93,25 @@
<Input title="Income" color="success" :entries="income" />
<Input title="Expenses" color="danger" :entries="expenses" />
</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>
</template>
@@ -48,8 +133,35 @@ export default {
in: 0,
out: 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 () {
@@ -60,6 +172,8 @@ export default {
if (storageObj.expenses && storageObj.income) {
this.$set(this, 'expenses', storageObj.expenses)
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) {
}
@@ -87,13 +201,213 @@ export default {
out: 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)
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)
const inList = [...this.income.map(i => {
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
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.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)
}
}
}