Add transaction list for accounts
This commit is contained in:
parent
6eb231f4d0
commit
5779e9e8a9
11 changed files with 841 additions and 17 deletions
502
frontend/components/accountOverview.vue
Normal file
502
frontend/components/accountOverview.vue
Normal file
|
@ -0,0 +1,502 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<!-- TODO: Add time-selector -->
|
||||||
|
<range-selector
|
||||||
|
v-model="timeRange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col fs-4 text-center">
|
||||||
|
{{ accountIdToName[accountId] }}
|
||||||
|
</div>
|
||||||
|
<div class="col d-flex align-items-center justify-content-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click="showAddTransaction = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-plus-circle mr-1" />
|
||||||
|
Add Transaction
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#transferMoneyModal"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
|
||||||
|
Add Transfer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn text-danger"
|
||||||
|
:disabled="selectedTx.length < 1"
|
||||||
|
@click="deleteSelected"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-trash mr-1" />
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col">
|
||||||
|
<table class="table table-striped small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="minimized-column">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateSelectAll"
|
||||||
|
>
|
||||||
|
</th>
|
||||||
|
<th class="minimized-column">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th>Payee</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="minimized-column text-end">
|
||||||
|
Amount
|
||||||
|
</th>
|
||||||
|
<th class="minimized-column">
|
||||||
|
<i class="fas fa-copyright" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="showAddTransaction">
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="form.date"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="date"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="form.payee"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
v-model="form.category"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.id"
|
||||||
|
:value="cat.id"
|
||||||
|
>
|
||||||
|
{{ cat.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="form.amount"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
@keypress.enter="createTransaction"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<input
|
||||||
|
v-model="form.cleared"
|
||||||
|
type="checkbox"
|
||||||
|
@keypress.enter="createTransaction"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="tx in sortedTransactions"
|
||||||
|
:key="tx.id"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="selectedTxRaw[tx.id]"
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="minimized-column">
|
||||||
|
{{ new Date(tx.time).toLocaleDateString() }}
|
||||||
|
</td>
|
||||||
|
<td>{{ tx.payee }}</td>
|
||||||
|
<td>{{ accountIdToName[tx.category] }}</td>
|
||||||
|
<td>{{ tx.description }}</td>
|
||||||
|
<td :class="{'minimized-column text-end': true, 'text-danger': tx.amount < 0}">
|
||||||
|
{{ formatNumber(tx.amount) }} €
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
:class="{'text-decoration-none':true, 'text-muted': !tx.cleared, 'text-success': tx.cleared}"
|
||||||
|
@click.prevent="markCleared(tx.id, !tx.cleared)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-copyright" />
|
||||||
|
</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="acc in transferableAccounts"
|
||||||
|
:key="acc.id"
|
||||||
|
:value="acc.id"
|
||||||
|
>
|
||||||
|
{{ acc.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="acc in transferableAccounts"
|
||||||
|
:key="acc.id"
|
||||||
|
:value="acc.id"
|
||||||
|
>
|
||||||
|
{{ acc.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label
|
||||||
|
for="transferMoneyModalCategory"
|
||||||
|
class="form-label"
|
||||||
|
>Category</label>
|
||||||
|
<select
|
||||||
|
id="transferMoneyModalCategory"
|
||||||
|
v-model="modals.createTransfer.category"
|
||||||
|
class="form-select"
|
||||||
|
:disabled="!transferCategoryActive"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="cat in categories"
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* eslint-disable sort-imports */
|
||||||
|
import { Modal } from 'bootstrap'
|
||||||
|
|
||||||
|
import { formatNumber, responseToJSON } from '../helpers'
|
||||||
|
import rangeSelector from './rangeSelector.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { rangeSelector },
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
accountIdToName() {
|
||||||
|
return Object.fromEntries(this.accounts.map(acc => [acc.id, acc.name]))
|
||||||
|
},
|
||||||
|
|
||||||
|
accountTypes() {
|
||||||
|
return Object.fromEntries(this.accounts.map(acc => [acc.id, acc.type]))
|
||||||
|
},
|
||||||
|
|
||||||
|
categories() {
|
||||||
|
const cats = this.accounts.filter(acc => acc.type === 'category')
|
||||||
|
cats.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return cats
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedTx() {
|
||||||
|
return Object.entries(this.selectedTxRaw)
|
||||||
|
.filter(e => e[1])
|
||||||
|
.map(e => e[0])
|
||||||
|
},
|
||||||
|
|
||||||
|
sortedTransactions() {
|
||||||
|
const tx = [...this.transactions]
|
||||||
|
tx.sort((b, a) => new Date(a.time).getTime() - new Date(b.time).getTime())
|
||||||
|
return tx
|
||||||
|
},
|
||||||
|
|
||||||
|
transferCategoryActive() {
|
||||||
|
return this.modals.createTransfer.from && this.modals.createTransfer.to && this.accountTypes[this.modals.createTransfer.from] !== this.accountTypes[this.modals.createTransfer.to]
|
||||||
|
},
|
||||||
|
|
||||||
|
transferModalValid() {
|
||||||
|
if (!this.modals.createTransfer.from || !this.modals.createTransfer.to) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modals.createTransfer.from === this.modals.createTransfer.to) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const tFrom = this.accountTypes[this.modals.createTransfer.from]
|
||||||
|
const tTo = this.accountTypes[this.modals.createTransfer.to]
|
||||||
|
if (tFrom !== tTo && !this.modals.createTransfer.category) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tFrom === tTo && this.modals.createTransfer.category) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
transferableAccounts() {
|
||||||
|
const accs = (this.accounts || []).filter(acc => acc.type !== 'category')
|
||||||
|
accs.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return accs
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
amount: 0,
|
||||||
|
category: '',
|
||||||
|
cleared: false,
|
||||||
|
date: new Date().toISOString()
|
||||||
|
.split('T')[0],
|
||||||
|
|
||||||
|
description: '',
|
||||||
|
payee: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
modals: {
|
||||||
|
createTransfer: {
|
||||||
|
amount: 0,
|
||||||
|
category: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedTxRaw: {},
|
||||||
|
showAddTransaction: false,
|
||||||
|
timeRange: {},
|
||||||
|
transactions: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
createTransaction() {
|
||||||
|
return fetch('/api/transactions', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
...this.form,
|
||||||
|
account: this.accountId,
|
||||||
|
time: new Date(this.form.date),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(responseToJSON)
|
||||||
|
.then(() => {
|
||||||
|
this.$emit('update-accounts')
|
||||||
|
this.fetchTransactions()
|
||||||
|
|
||||||
|
this.showAddTransaction = false
|
||||||
|
this.form = {
|
||||||
|
amount: 0,
|
||||||
|
category: '',
|
||||||
|
cleared: false,
|
||||||
|
date: this.form.date,
|
||||||
|
description: '',
|
||||||
|
payee: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSelected() {
|
||||||
|
const actions = []
|
||||||
|
for (const id of this.selectedTx) {
|
||||||
|
actions.push(fetch(`/api/transactions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(actions)
|
||||||
|
.then(() => {
|
||||||
|
this.$emit('update-accounts')
|
||||||
|
this.fetchTransactions()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchTransactions() {
|
||||||
|
const since = this.timeRange.start.toISOString()
|
||||||
|
const until = this.timeRange.end.toISOString()
|
||||||
|
|
||||||
|
return fetch(`/api/accounts/${this.accountId}/transactions?since=${since}&until=${until}`)
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(txs => {
|
||||||
|
this.transactions = txs
|
||||||
|
this.selectedTxRaw = {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
formatNumber,
|
||||||
|
|
||||||
|
markCleared(txId, cleared) {
|
||||||
|
return fetch(`/api/transactions/${txId}?cleared=${cleared}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
})
|
||||||
|
.then(() => this.fetchTransactions())
|
||||||
|
},
|
||||||
|
|
||||||
|
transferMoney() {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('amount', this.modals.createTransfer.amount.toFixed(2))
|
||||||
|
if (this.modals.createTransfer.category) {
|
||||||
|
params.set('category', this.modals.createTransfer.category)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(`/api/accounts/${this.modals.createTransfer.from}/transfer/${this.modals.createTransfer.to}?${params.toString()}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$emit('update-accounts')
|
||||||
|
Modal.getInstance(this.$refs.transferMoneyModal).toggle()
|
||||||
|
|
||||||
|
this.modals.createTransfer = {
|
||||||
|
amount: 0,
|
||||||
|
category: '',
|
||||||
|
from: '',
|
||||||
|
to: this.accountId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSelectAll(evt) {
|
||||||
|
this.selectedTxRaw = Object.fromEntries(this.transactions.map(tx => [tx.id, evt.target.checked]))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.modals.createTransfer.to = this.accountId
|
||||||
|
this.fetchTransactions()
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AccountingAppAccountOverview',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
accountId: {
|
||||||
|
required: true,
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
accounts: {
|
||||||
|
required: true,
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
accountId(to) {
|
||||||
|
this.modals.createTransfer.to = to
|
||||||
|
this.fetchTransactions()
|
||||||
|
},
|
||||||
|
|
||||||
|
timeRange() {
|
||||||
|
this.fetchTransactions()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -22,7 +22,7 @@
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<i class="fas fa-fw fa-credit-card me-1" /> Budget
|
<i class="fas fa-fw fa-credit-card me-1" /> Budget
|
||||||
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
|
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
|
||||||
{{ budgetSum.toFixed(2) }} €
|
{{ formatNumber(budgetSum) }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||||
|
@ -31,11 +31,11 @@
|
||||||
v-for="acc in budgetAccounts"
|
v-for="acc in budgetAccounts"
|
||||||
:key="acc.id"
|
:key="acc.id"
|
||||||
class="d-flex align-items-center text-white text-decoration-none"
|
class="d-flex align-items-center text-white text-decoration-none"
|
||||||
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
:to="{ name: 'account-transactions', params: { accountId: acc.id }}"
|
||||||
>
|
>
|
||||||
{{ acc.name }}
|
{{ acc.name }}
|
||||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||||
{{ acc.balance.toFixed(2) }} €
|
{{ formatNumber(acc.balance) }} €
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
@ -45,7 +45,7 @@
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<i class="fas fa-fw fa-coin me-1" /> Tracking
|
<i class="fas fa-fw fa-coin me-1" /> Tracking
|
||||||
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
|
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
|
||||||
{{ trackingSum.toFixed(2) }} €
|
{{ formatNumber(trackingSum) }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||||
|
@ -54,11 +54,11 @@
|
||||||
v-for="acc in trackingAccounts"
|
v-for="acc in trackingAccounts"
|
||||||
:key="acc.id"
|
:key="acc.id"
|
||||||
class="d-flex align-items-center text-white text-decoration-none"
|
class="d-flex align-items-center text-white text-decoration-none"
|
||||||
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
:to="{ name: 'account-transactions', params: { accountId: acc.id }}"
|
||||||
>
|
>
|
||||||
{{ acc.name }}
|
{{ acc.name }}
|
||||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||||
{{ acc.balance.toFixed(2) }} €
|
{{ formatNumber(acc.balance) }} €
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
@ -79,14 +79,14 @@
|
||||||
ref="createAccountModal"
|
ref="createAccountModal"
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-labelledby="exampleModalLabel"
|
aria-labelledby="createAccountModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div class="modal-dialog modal-sm">
|
<div class="modal-dialog modal-sm">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1
|
<h1
|
||||||
id="exampleModalLabel"
|
id="createAccountModalLabel"
|
||||||
class="modal-title fs-5"
|
class="modal-title fs-5"
|
||||||
>
|
>
|
||||||
Add Account
|
Add Account
|
||||||
|
@ -166,8 +166,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/* eslint-disable sort-imports */
|
||||||
import { Modal } from 'bootstrap'
|
import { Modal } from 'bootstrap'
|
||||||
|
|
||||||
|
import { formatNumber } from '../helpers'
|
||||||
import { unallocatedMoneyAcc } from '../constants'
|
import { unallocatedMoneyAcc } from '../constants'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -266,6 +268,8 @@ export default {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatNumber,
|
||||||
},
|
},
|
||||||
|
|
||||||
name: 'AccountingAppSidebar',
|
name: 'AccountingAppSidebar',
|
||||||
|
|
|
@ -10,7 +10,10 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column flex-grow-1 py-3">
|
<div class="d-flex flex-column flex-grow-1 py-3">
|
||||||
<router-view :accounts="accounts" />
|
<router-view
|
||||||
|
:accounts="accounts"
|
||||||
|
@updateAccounts="fetchAccounts"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col d-flex align-items-center">
|
||||||
<!-- TODO: Add time-selector -->
|
<range-selector
|
||||||
|
v-model="timeRange"
|
||||||
|
:multi-month="false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col d-flex align-items-center justify-content-end">
|
<div class="col d-flex align-items-center justify-content-end">
|
||||||
<div :class="unallocatedMoneyClass">
|
<div :class="unallocatedMoneyClass">
|
||||||
<span class="fs-4">{{ unallocatedMoney.toFixed(2) }} €</span>
|
<span class="fs-4">{{ formatNumber(unallocatedMoney) }} €</span>
|
||||||
<span class="small">Unallocated</span>
|
<span class="small">Unallocated</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,6 +20,9 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
|
<th class="text-end">
|
||||||
|
Allocated
|
||||||
|
</th>
|
||||||
<th class="text-end">
|
<th class="text-end">
|
||||||
Activity
|
Activity
|
||||||
</th>
|
</th>
|
||||||
|
@ -31,9 +37,14 @@
|
||||||
:key="cat.id"
|
:key="cat.id"
|
||||||
>
|
>
|
||||||
<td>{{ cat.name }}</td>
|
<td>{{ cat.name }}</td>
|
||||||
<td> </td>
|
<td :class="{'text-end': true, 'text-danger': (allocatedByCategory[cat.id] || 0) < 0}">
|
||||||
|
{{ formatNumber(allocatedByCategory[cat.id] || 0) }} €
|
||||||
|
</td>
|
||||||
|
<td :class="{'text-end': true, 'text-danger': (activityByCategory[cat.id] || 0) < 0}">
|
||||||
|
{{ formatNumber(activityByCategory[cat.id] || 0) }} €
|
||||||
|
</td>
|
||||||
<td :class="{'text-end': true, 'text-danger': cat.balance < 0}">
|
<td :class="{'text-end': true, 'text-danger': cat.balance < 0}">
|
||||||
{{ cat.balance.toFixed(2) }} €
|
{{ formatNumber(cat.balance) }} €
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -44,10 +55,32 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { formatNumber } from '../helpers'
|
||||||
|
import rangeSelector from './rangeSelector.vue'
|
||||||
import { unallocatedMoneyAcc } from '../constants'
|
import { unallocatedMoneyAcc } from '../constants'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { rangeSelector },
|
||||||
|
|
||||||
computed: {
|
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() {
|
categories() {
|
||||||
const accounts = this.accounts
|
const accounts = this.accounts
|
||||||
.filter(acc => acc.type === 'category')
|
.filter(acc => acc.type === 'category')
|
||||||
|
@ -78,6 +111,28 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
timeRange: {},
|
||||||
|
transactions: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
|
||||||
name: 'AccountingAppBudgetDashboard',
|
name: 'AccountingAppBudgetDashboard',
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
|
@ -86,5 +141,11 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
timeRange() {
|
||||||
|
this.fetchTransactions()
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
165
frontend/components/rangeSelector.vue
Normal file
165
frontend/components/rangeSelector.vue
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-inline-flex align-items-center">
|
||||||
|
<span
|
||||||
|
v-if="multiMonth"
|
||||||
|
class="me-1"
|
||||||
|
>From:</span>
|
||||||
|
<div class="input-group">
|
||||||
|
<select
|
||||||
|
v-model="dateComponents.fromMonth"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(name, idx) in monthNames"
|
||||||
|
:key="idx"
|
||||||
|
:value="idx"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="dateComponents.fromYear"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="year in years"
|
||||||
|
:key="year"
|
||||||
|
:value="year"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<template v-if="multiMonth">
|
||||||
|
<span class="me-1 ms-2">To:</span>
|
||||||
|
<div class="input-group">
|
||||||
|
<select
|
||||||
|
v-model="dateComponents.toMonth"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(name, idx) in monthNames"
|
||||||
|
:key="idx"
|
||||||
|
:value="idx"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="dateComponents.toYear"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="year in years"
|
||||||
|
:key="year"
|
||||||
|
:value="year"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
monthNames() {
|
||||||
|
const format = new Intl
|
||||||
|
.DateTimeFormat(undefined, { month: 'long' }).format
|
||||||
|
return [...Array(12).keys()]
|
||||||
|
.map(m => format(new Date(Date.UTC(2021, m))))
|
||||||
|
},
|
||||||
|
|
||||||
|
years() {
|
||||||
|
return [...Array(this.endYear - this.startYear).keys()]
|
||||||
|
.map(y => y + this.startYear)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const date = new Date()
|
||||||
|
const start = this.modelValue.start || new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
const end = this.modelValue.end || new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||||
|
|
||||||
|
this.dateComponents = {
|
||||||
|
fromMonth: start.getMonth(),
|
||||||
|
fromYear: start.getFullYear(),
|
||||||
|
toMonth: end.getMonth(),
|
||||||
|
toYear: end.getFullYear(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dateComponents: {
|
||||||
|
fromMonth: 0,
|
||||||
|
fromYear: 0,
|
||||||
|
toMonth: 0,
|
||||||
|
toYear: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
emitRange() {
|
||||||
|
this.start = new Date(this.dateComponents.fromYear, this.dateComponents.fromMonth, 1)
|
||||||
|
if (this.multiMonth) {
|
||||||
|
this.end = new Date(this.dateComponents.toYear, this.dateComponents.toMonth + 1, 1, 0)
|
||||||
|
} else {
|
||||||
|
this.end = new Date(this.dateComponents.fromYear, this.dateComponents.fromMonth + 1, 1, 0)
|
||||||
|
}
|
||||||
|
this.$emit('update:modelValue', { end: this.end, start: this.start })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.emitRange()
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AccountingAppRangeSelector',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
endYear: {
|
||||||
|
default: 2034,
|
||||||
|
type: Number,
|
||||||
|
},
|
||||||
|
|
||||||
|
modelValue: {
|
||||||
|
default: () => ({}),
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
multiMonth: {
|
||||||
|
default: true,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
startYear: {
|
||||||
|
default: 2020,
|
||||||
|
type: Number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'dateComponents.fromMonth'() {
|
||||||
|
this.emitRange()
|
||||||
|
},
|
||||||
|
|
||||||
|
'dateComponents.fromYear'() {
|
||||||
|
this.emitRange()
|
||||||
|
},
|
||||||
|
|
||||||
|
'dateComponents.toMonth'() {
|
||||||
|
this.emitRange()
|
||||||
|
},
|
||||||
|
|
||||||
|
'dateComponents.toYear'() {
|
||||||
|
this.emitRange()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
43
frontend/helpers.js
Normal file
43
frontend/helpers.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
export function formatNumber(number, thousandSep = ' ', decimalSep = '.', places = 2) {
|
||||||
|
if (isNaN(number)) {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix x.99999999999 database fuckups
|
||||||
|
number = Math.round(number * Math.pow(10, 2)) / Math.pow(10, 2)
|
||||||
|
|
||||||
|
let result = number < 0 ? '-' : ''
|
||||||
|
number = Math.abs(number)
|
||||||
|
if (number >= Number.MAX_SAFE_INTEGER) {
|
||||||
|
return result + number.toFixed(places)
|
||||||
|
}
|
||||||
|
|
||||||
|
let place = Math.ceil(Math.log10(number))
|
||||||
|
|
||||||
|
if (place < 3) {
|
||||||
|
return result + number.toFixed(places).replace('.', decimalSep)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (place--) {
|
||||||
|
result += number / 10 ** place % 10 | 0
|
||||||
|
if (place > 0 && place % 3 === 0) {
|
||||||
|
result += thousandSep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result + decimalSep + number.toFixed(places).split('.')[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the response to JSON and throws an exception in case the
|
||||||
|
* request was non-2xx
|
||||||
|
*
|
||||||
|
* @param {Response} resp Response from a `fetch` request
|
||||||
|
*/
|
||||||
|
export function responseToJSON(resp) {
|
||||||
|
if (resp.status > 299) {
|
||||||
|
throw new Error(`non-2xx status code: ${resp.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import accountOverview from './components/accountOverview.vue'
|
||||||
import budgetDashboard from './components/budgetDashboard.vue'
|
import budgetDashboard from './components/budgetDashboard.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ component: budgetDashboard, name: 'budget', path: '/' },
|
{ component: budgetDashboard, name: 'budget', path: '/' },
|
||||||
{ component: null, name: 'account-transactions', path: '/accounts/:id' },
|
{ component: accountOverview, name: 'account-transactions', path: '/accounts/:accountId', props: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
@ -7,4 +7,9 @@ $web-font-path: '';
|
||||||
$fa-font-path: "../node_modules/@fortawesome/fontawesome-pro/webfonts";
|
$fa-font-path: "../node_modules/@fortawesome/fontawesome-pro/webfonts";
|
||||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/fontawesome.scss";
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/fontawesome.scss";
|
||||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/solid.scss";
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/solid.scss";
|
||||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/brands.scss";
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/brands.scss";
|
||||||
|
|
||||||
|
.minimized-column {
|
||||||
|
width: 0.1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
|
@ -45,6 +45,9 @@ func RegisterHandler(apiRouter *mux.Router, dbc *database.Client, logger *logrus
|
||||||
HandleFunc("/accounts/{id}/transfer/{to}", as.handleTransferMoney).
|
HandleFunc("/accounts/{id}/transfer/{to}", as.handleTransferMoney).
|
||||||
Methods(http.MethodPut)
|
Methods(http.MethodPut)
|
||||||
|
|
||||||
|
apiRouter.
|
||||||
|
HandleFunc("/transactions", as.handleListTransactions).
|
||||||
|
Methods(http.MethodGet)
|
||||||
apiRouter.
|
apiRouter.
|
||||||
HandleFunc("/transactions", as.handleCreateTransaction).
|
HandleFunc("/transactions", as.handleCreateTransaction).
|
||||||
Methods(http.MethodPost)
|
Methods(http.MethodPost)
|
||||||
|
|
|
@ -75,6 +75,27 @@ func (a apiServer) handleGetTransactionByID(w http.ResponseWriter, r *http.Reque
|
||||||
a.jsonResponse(w, http.StatusOK, tx)
|
a.jsonResponse(w, http.StatusOK, tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a apiServer) handleListTransactions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
since time.Time
|
||||||
|
until = time.Now()
|
||||||
|
)
|
||||||
|
if v, err := time.Parse(time.RFC3339, r.URL.Query().Get("since")); err == nil {
|
||||||
|
since = v
|
||||||
|
}
|
||||||
|
if v, err := time.Parse(time.RFC3339, r.URL.Query().Get("until")); err == nil {
|
||||||
|
until = v
|
||||||
|
}
|
||||||
|
|
||||||
|
txs, err := a.dbc.ListTransactions(since, until)
|
||||||
|
if err != nil {
|
||||||
|
a.errorResponse(w, err, "getting transactions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.jsonResponse(w, http.StatusOK, txs)
|
||||||
|
}
|
||||||
|
|
||||||
func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *http.Request) {
|
func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
accid, err := uuid.Parse(mux.Vars(r)["id"])
|
accid, err := uuid.Parse(mux.Vars(r)["id"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -6,6 +6,7 @@ package database
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/backoff"
|
"github.com/Luzifer/go_helpers/v2/backoff"
|
||||||
|
@ -173,7 +174,8 @@ func (c *Client) ListAccountBalances(showHidden bool) (a []AccountBalance, err e
|
||||||
}
|
}
|
||||||
|
|
||||||
if v != nil {
|
if v != nil {
|
||||||
ab.Balance = *v
|
// Fix database doing e-15 stuff by rounding to full cents
|
||||||
|
ab.Balance = math.Round(*v*100) / 100 //nolint:gomnd
|
||||||
}
|
}
|
||||||
|
|
||||||
a = append(a, ab)
|
a = append(a, ab)
|
||||||
|
@ -224,6 +226,20 @@ func (c *Client) ListAccountsByType(at AccountType, showHidden bool) (a []Accoun
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTransactions retrieves all transactions
|
||||||
|
func (c *Client) ListTransactions(since, until time.Time) (txs []Transaction, err error) {
|
||||||
|
if err = c.retryRead(func(db *gorm.DB) error {
|
||||||
|
return db.
|
||||||
|
Where("time >= ? and time <= ?", since, until).
|
||||||
|
Find(&txs).
|
||||||
|
Error
|
||||||
|
}); err != nil {
|
||||||
|
return txs, fmt.Errorf("listing transactions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTransactionsByAccount retrieves all transactions for an account
|
// ListTransactionsByAccount retrieves all transactions for an account
|
||||||
// or category
|
// or category
|
||||||
func (c *Client) ListTransactionsByAccount(acc uuid.UUID, since, until time.Time) (txs []Transaction, err error) {
|
func (c *Client) ListTransactionsByAccount(acc uuid.UUID, since, until time.Time) (txs []Transaction, err error) {
|
||||||
|
|
Loading…
Reference in a new issue