accounting/frontend/components/budgetDashboard.vue
Knut Ahlers 91d2447728
Fix: Unallocated gets unreadable if negative
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-02-02 22:30:19 +01:00

382 lines
10 KiB
Vue

<template>
<div>
<div class="container-fluid">
<div class="row">
<div class="col d-flex align-items-center">
<range-selector
v-model="timeRange"
:multi-month="false"
/>
</div>
<div class="col d-flex align-items-center justify-content-center">
<div :class="unallocatedMoneyClass">
<span class="fs-4">{{ formatNumber(unallocatedMoney) }} </span>
<span class="small">Unallocated</span>
</div>
</div>
<div class="col d-flex align-items-center justify-content-end">
<div class="btn-group btn-group-sm">
<button
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#transferMoneyModal"
>
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
Add Transfer
</button>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<table class="table table-striped small">
<thead>
<tr>
<th>Category</th>
<th class="text-end">
Allocated
</th>
<th class="text-end">
Activity
</th>
<th class="text-end">
Available
</th>
</tr>
</thead>
<tbody>
<tr
v-for="cat in categories"
:key="cat.id"
>
<td>
<a
class="text-white text-decoration-none"
href="#"
title="Click to Edit"
@click.prevent="editedAccId = cat.id"
>
{{ cat.name }}
</a>
</td>
<td :class="classFromNumber(allocatedByCategory[cat.id] || 0, ['text-end'])">
{{ formatNumber(allocatedByCategory[cat.id] || 0) }} €
</td>
<td :class="classFromNumber(activityByCategory[cat.id] || 0, ['text-end'])">
{{ formatNumber(activityByCategory[cat.id] || 0) }} €
</td>
<td class="text-end">
<a
:class="classFromNumber(cat.balance, ['text-decoration-none'], 'text-white')"
href="#"
title="Transfer Money using this Category"
@click.prevent="initTransfer(cat.id)"
>
{{ formatNumber(cat.balance) }} €
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div
id="transferMoneyModal"
ref="transferMoneyModal"
class="modal fade"
tabindex="-1"
aria-labelledby="transferMoneyModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h1
id="transferMoneyModalLabel"
class="modal-title fs-5"
>
Transfer Money
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
/>
</div>
<div class="modal-body">
<div class="mb-3">
<label
for="transferMoneyModalFrom"
class="form-label"
>From</label>
<select
id="transferMoneyModalFrom"
v-model="modals.createTransfer.from"
class="form-select"
>
<option
v-for="cat in transferableCategories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<div class="mb-3">
<label
for="transferMoneyModalTo"
class="form-label"
>To</label>
<select
id="transferMoneyModalTo"
v-model="modals.createTransfer.to"
class="form-select"
>
<option
v-for="cat in transferableCategories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<div class="mb-3">
<label
for="transferMoneyModalAmount"
class="form-label"
>Amount</label>
<div class="input-group">
<input
id="transferMoneyModalAmount"
v-model.number="modals.createTransfer.amount"
type="number"
min="0.01"
step="0.01"
class="form-control text-end"
>
<span class="input-group-text">€</span>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-primary"
:disabled="!transferModalValid"
@click="transferMoney"
>
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
Transfer
</button>
</div>
</div>
</div>
</div>
<account-editor
:account="editedAcc"
@editClosed="editedAccId = null"
@editComplete="$emit('update-accounts'); editedAccId = null"
/>
</div>
</template>
<script>
/* eslint-disable sort-imports */
import { Modal } from 'bootstrap'
import accountEditor from './accountEditor.vue'
import { classFromNumber, formatNumber } from '../helpers'
import rangeSelector from './rangeSelector.vue'
import { unallocatedMoneyAcc } from '../constants'
export default {
components: { accountEditor, rangeSelector },
computed: {
activityByCategory() {
return this.transactions
.filter(tx => tx.account)
.reduce((alloc, tx) => {
alloc[tx.category] = (alloc[tx.category] || 0) + tx.amount
return alloc
}, {})
},
allocatedByCategory() {
return this.transactions
.filter(tx => !tx.account)
.reduce((alloc, tx) => {
alloc[tx.category] = (alloc[tx.category] || 0) + tx.amount
return alloc
}, {})
},
categories() {
const accounts = this.accounts
.filter(acc => acc.type === 'category')
.filter(acc => acc.id !== unallocatedMoneyAcc)
accounts.sort((a, b) => a.name.localeCompare(b.name))
return accounts
},
editedAcc() {
if (!this.editedAccId) {
return null
}
return this.accounts.filter(acc => acc.id === this.editedAccId)[0] || null
},
transferModalValid() {
if (!this.modals.createTransfer.from || !this.modals.createTransfer.to) {
return false
}
if (this.modals.createTransfer.from === this.modals.createTransfer.to) {
return false
}
return true
},
transferableCategories() {
const accounts = this.accounts
.filter(acc => acc.type === 'category')
accounts.sort((a, b) => a.name.localeCompare(b.name))
return accounts
},
unallocatedMoney() {
const acc = this.accounts.filter(acc => acc.id === unallocatedMoneyAcc)[0] || null
if (acc === null) {
return 0
}
return acc.balance
},
unallocatedMoneyClass() {
const classes = ['d-inline-flex', 'flex-column', 'text-center', 'p-2', 'rounded']
if (this.unallocatedMoney < 0) {
classes.push('bg-danger')
} else if (this.unallocatedMoney === 0) {
classes.push('bg-warning')
} else {
classes.push('bg-success')
}
return classes.join(' ')
},
},
data() {
return {
editedAccId: null,
modals: {
createTransfer: {
amount: 0,
from: unallocatedMoneyAcc,
to: '',
},
},
timeRange: {},
transactions: [],
}
},
emits: ['update-accounts'],
methods: {
classFromNumber,
fetchTransactions() {
const since = this.timeRange.start.toISOString()
const until = this.timeRange.end.toISOString()
return fetch(`/api/transactions?since=${since}&until=${until}`)
.then(resp => resp.json())
.then(txs => {
this.transactions = txs
})
},
formatNumber,
initTransfer(catId) {
const cat = this.categories.filter(cat => cat.id === catId)[0] || null
if (!cat) {
console.error(`init transfer from non existent category ${catId}`)
return
}
this.modals.createTransfer = {
amount: 0,
from: '',
to: '',
}
if (cat.balance <= 0) {
// Most likely scenario: Transfer in
this.modals.createTransfer.from = unallocatedMoneyAcc
this.modals.createTransfer.to = cat.id
} else {
// Most likely scenario: Transfer out
this.modals.createTransfer.from = cat.id
}
Modal.getOrCreateInstance(this.$refs.transferMoneyModal).toggle()
},
resetTransfer() {
this.modals.createTransfer = {
amount: 0,
from: unallocatedMoneyAcc,
to: '',
}
},
transferMoney() {
const params = new URLSearchParams()
params.set('amount', this.modals.createTransfer.amount.toFixed(2))
return fetch(`/api/accounts/${this.modals.createTransfer.from}/transfer/${this.modals.createTransfer.to}?${params.toString()}`, {
method: 'PUT',
})
.then(() => {
this.$emit('update-accounts')
this.fetchTransactions()
Modal.getInstance(this.$refs.transferMoneyModal).toggle()
})
},
},
mounted() {
this.$refs.transferMoneyModal
.addEventListener('hidden.bs.modal', () => this.resetTransfer())
},
name: 'AccountingAppBudgetDashboard',
props: {
accounts: {
required: true,
type: Array,
},
},
watch: {
timeRange() {
this.fetchTransactions()
},
},
}
</script>