<template> <div id="app" class="container"> <b-row class="results"> <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> <b-row class="inputs"> <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> <script> import Input from './components/Input' import Frequency from './enums/Frequency' export default { name: 'App', components: { Input }, data () { return { income: [], expenses: [], calculated: { yearly: { 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 () { const storage = localStorage.getItem('expenses') if (storage) { try { const storageObj = JSON.parse(storage) 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) { } } this.calculate() }, watch: { income: { deep: true, handler: 'calculate' }, expenses: { deep: true, handler: 'calculate' } }, methods: { calculate () { const storage = JSON.stringify({expenses: this.expenses, income: this.income}) localStorage.setItem('expenses', storage) let calc = { in: 0, 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] 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) } } } </script> <style> body { overflow-y: scroll; } .inputs { margin-top: 20px; } /* width */ ::-webkit-scrollbar { width: 12px; } /* Track */ ::-webkit-scrollbar-track { background: var(--gray); } /* Handle */ ::-webkit-scrollbar-thumb { background: var(--dark); border-radius: 6px 6px 6px 6px / 12px 12px 12px 12px; } /* Handle on hover */ ::-webkit-scrollbar-thumb:hover { background: #555; } </style>