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">
|
||||
<i class="fas fa-fw fa-credit-card me-1" /> Budget
|
||||
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
|
||||
{{ budgetSum.toFixed(2) }} €
|
||||
{{ formatNumber(budgetSum) }} €
|
||||
</span>
|
||||
</div>
|
||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||
|
@ -31,11 +31,11 @@
|
|||
v-for="acc in budgetAccounts"
|
||||
:key="acc.id"
|
||||
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 }}
|
||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||
{{ acc.balance.toFixed(2) }} €
|
||||
{{ formatNumber(acc.balance) }} €
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
|
@ -45,7 +45,7 @@
|
|||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-fw fa-coin me-1" /> Tracking
|
||||
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
|
||||
{{ trackingSum.toFixed(2) }} €
|
||||
{{ formatNumber(trackingSum) }} €
|
||||
</span>
|
||||
</div>
|
||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||
|
@ -54,11 +54,11 @@
|
|||
v-for="acc in trackingAccounts"
|
||||
:key="acc.id"
|
||||
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 }}
|
||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||
{{ acc.balance.toFixed(2) }} €
|
||||
{{ formatNumber(acc.balance) }} €
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
|
@ -79,14 +79,14 @@
|
|||
ref="createAccountModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="exampleModalLabel"
|
||||
aria-labelledby="createAccountModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1
|
||||
id="exampleModalLabel"
|
||||
id="createAccountModalLabel"
|
||||
class="modal-title fs-5"
|
||||
>
|
||||
Add Account
|
||||
|
@ -166,8 +166,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable sort-imports */
|
||||
import { Modal } from 'bootstrap'
|
||||
|
||||
import { formatNumber } from '../helpers'
|
||||
import { unallocatedMoneyAcc } from '../constants'
|
||||
|
||||
export default {
|
||||
|
@ -266,6 +268,8 @@ export default {
|
|||
}
|
||||
})
|
||||
},
|
||||
|
||||
formatNumber,
|
||||
},
|
||||
|
||||
name: 'AccountingAppSidebar',
|
||||
|
|
|
@ -10,7 +10,10 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 py-3">
|
||||
<router-view :accounts="accounts" />
|
||||
<router-view
|
||||
:accounts="accounts"
|
||||
@updateAccounts="fetchAccounts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<template>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<!-- TODO: Add time-selector -->
|
||||
<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-end">
|
||||
<div :class="unallocatedMoneyClass">
|
||||
<span class="fs-4">{{ unallocatedMoney.toFixed(2) }} €</span>
|
||||
<span class="fs-4">{{ formatNumber(unallocatedMoney) }} €</span>
|
||||
<span class="small">Unallocated</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -17,6 +20,9 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th class="text-end">
|
||||
Allocated
|
||||
</th>
|
||||
<th class="text-end">
|
||||
Activity
|
||||
</th>
|
||||
|
@ -31,9 +37,14 @@
|
|||
:key="cat.id"
|
||||
>
|
||||
<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}">
|
||||
{{ cat.balance.toFixed(2) }} €
|
||||
{{ formatNumber(cat.balance) }} €
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -44,10 +55,32 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { formatNumber } from '../helpers'
|
||||
import rangeSelector from './rangeSelector.vue'
|
||||
import { unallocatedMoneyAcc } from '../constants'
|
||||
|
||||
export default {
|
||||
components: { 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')
|
||||
|
@ -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',
|
||||
|
||||
props: {
|
||||
|
@ -86,5 +141,11 @@ export default {
|
|||
type: Array,
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
timeRange() {
|
||||
this.fetchTransactions()
|
||||
},
|
||||
},
|
||||
}
|
||||
</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 accountOverview from './components/accountOverview.vue'
|
||||
import budgetDashboard from './components/budgetDashboard.vue'
|
||||
|
||||
const routes = [
|
||||
{ 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({
|
||||
|
|
|
@ -7,4 +7,9 @@ $web-font-path: '';
|
|||
$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/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).
|
||||
Methods(http.MethodPut)
|
||||
|
||||
apiRouter.
|
||||
HandleFunc("/transactions", as.handleListTransactions).
|
||||
Methods(http.MethodGet)
|
||||
apiRouter.
|
||||
HandleFunc("/transactions", as.handleCreateTransaction).
|
||||
Methods(http.MethodPost)
|
||||
|
|
|
@ -75,6 +75,27 @@ func (a apiServer) handleGetTransactionByID(w http.ResponseWriter, r *http.Reque
|
|||
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) {
|
||||
accid, err := uuid.Parse(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
|
|
|
@ -6,6 +6,7 @@ package database
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/backoff"
|
||||
|
@ -173,7 +174,8 @@ func (c *Client) ListAccountBalances(showHidden bool) (a []AccountBalance, err e
|
|||
}
|
||||
|
||||
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)
|
||||
|
@ -224,6 +226,20 @@ func (c *Client) ListAccountsByType(at AccountType, showHidden bool) (a []Accoun
|
|||
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
|
||||
// or category
|
||||
func (c *Client) ListTransactionsByAccount(acc uuid.UUID, since, until time.Time) (txs []Transaction, err error) {
|
||||
|
|
Loading…
Reference in a new issue