Allow to edit transactions
improve keyboard usability of transaction editor Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
9934d49b7c
commit
2c38247221
6 changed files with 322 additions and 133 deletions
|
@ -8,7 +8,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col d-flex text-center flex-column">
|
<div class="col d-flex text-center flex-column">
|
||||||
<span class="fs-3 text-semibold">{{ accountIdToName[accountId] }}</span>
|
<span class="fs-3 mb-2 text-semibold">{{ accountIdToName[accountId] }}</span>
|
||||||
<div class="d-flex align-items-center mx-auto">
|
<div class="d-flex align-items-center mx-auto">
|
||||||
<div class="d-inline-flex text-center flex-column mx-4">
|
<div class="d-inline-flex text-center flex-column mx-4">
|
||||||
{{ formatNumber(account.balance - balanceUncleared) }} €
|
{{ formatNumber(account.balance - balanceUncleared) }} €
|
||||||
|
@ -35,28 +35,49 @@
|
||||||
<div class="col d-flex align-items-center justify-content-end">
|
<div class="col d-flex align-items-center justify-content-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn btn-primary"
|
||||||
@click="showAddTransaction = !showAddTransaction"
|
@click="showAddTransaction = !showAddTransaction"
|
||||||
>
|
>
|
||||||
<i class="fas fa-fw fa-plus-circle mr-1" />
|
<i class="fas fa-fw fa-plus-circle mr-1" />
|
||||||
Add Transaction
|
Add Transaction
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn btn-primary"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#transferMoneyModal"
|
data-bs-target="#transferMoneyModal"
|
||||||
>
|
>
|
||||||
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
|
<i class="fas fa-fw fa-arrow-right-arrow-left mr-1" />
|
||||||
Add Transfer
|
Add Transfer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn text-danger"
|
class="btn btn-secondary"
|
||||||
|
:disabled="selectedTx.length !== 1"
|
||||||
|
@click="editSelected"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-pencil mr-1" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="dropdown-item text-danger"
|
||||||
:disabled="selectedTx.length < 1"
|
:disabled="selectedTx.length < 1"
|
||||||
@click="deleteSelected"
|
@click="deleteSelected"
|
||||||
>
|
>
|
||||||
<i class="fas fa-fw fa-trash mr-1" />
|
<i class="fas fa-fw fa-trash mr-1" />
|
||||||
Delete Selected
|
Delete Selected
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -88,63 +109,21 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="showAddTransaction">
|
<tx-editor
|
||||||
<td />
|
v-if="showAddTransaction"
|
||||||
<td>
|
:account="account"
|
||||||
<input
|
:accounts="accounts"
|
||||||
v-model="form.date"
|
@editCancelled="showAddTransaction = false"
|
||||||
class="form-control form-control-sm"
|
@editSaved="txSaved"
|
||||||
type="date"
|
/>
|
||||||
>
|
|
||||||
</td>
|
<template
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
v-model="form.payee"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td v-if="account.type !== 'tracking'">
|
|
||||||
<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"
|
v-for="tx in sortedTransactions"
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
v-if="tx.id !== editedTxId"
|
||||||
:key="tx.id"
|
:key="tx.id"
|
||||||
|
@dblclick="editTx(tx.id)"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
@ -173,6 +152,16 @@
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tx-editor
|
||||||
|
v-else
|
||||||
|
:key="tx.id"
|
||||||
|
:account="account"
|
||||||
|
:accounts="accounts"
|
||||||
|
:edit="tx"
|
||||||
|
@editCancelled="editedTxId = null"
|
||||||
|
@editSaved="txSaved"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -304,11 +293,12 @@
|
||||||
/* eslint-disable sort-imports */
|
/* eslint-disable sort-imports */
|
||||||
import { Modal } from 'bootstrap'
|
import { Modal } from 'bootstrap'
|
||||||
|
|
||||||
import { formatNumber, responseToJSON } from '../helpers'
|
import { formatNumber } from '../helpers'
|
||||||
import rangeSelector from './rangeSelector.vue'
|
import rangeSelector from './rangeSelector.vue'
|
||||||
|
import txEditor from './txEditor.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { rangeSelector },
|
components: { rangeSelector, txEditor },
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
account() {
|
account() {
|
||||||
|
@ -382,16 +372,7 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
form: {
|
editedTxId: null,
|
||||||
amount: 0,
|
|
||||||
category: '',
|
|
||||||
cleared: false,
|
|
||||||
date: new Date().toISOString()
|
|
||||||
.split('T')[0],
|
|
||||||
|
|
||||||
description: '',
|
|
||||||
payee: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
modals: {
|
modals: {
|
||||||
createTransfer: {
|
createTransfer: {
|
||||||
|
@ -410,36 +391,6 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
createTransaction() {
|
|
||||||
return fetch('/api/transactions', {
|
|
||||||
body: JSON.stringify({
|
|
||||||
...this.form,
|
|
||||||
account: this.accountId,
|
|
||||||
category: this.form.category || null,
|
|
||||||
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() {
|
deleteSelected() {
|
||||||
const actions = []
|
const actions = []
|
||||||
for (const id of this.selectedTx) {
|
for (const id of this.selectedTx) {
|
||||||
|
@ -455,6 +406,14 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
editSelected() {
|
||||||
|
this.editTx(this.selectedTx[0])
|
||||||
|
},
|
||||||
|
|
||||||
|
editTx(txId) {
|
||||||
|
this.editedTxId = txId
|
||||||
|
},
|
||||||
|
|
||||||
fetchTransactions() {
|
fetchTransactions() {
|
||||||
const since = this.timeRange.start.toISOString()
|
const since = this.timeRange.start.toISOString()
|
||||||
const until = this.timeRange.end.toISOString()
|
const until = this.timeRange.end.toISOString()
|
||||||
|
@ -500,6 +459,12 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
txSaved() {
|
||||||
|
this.editedTxId = null
|
||||||
|
this.$emit('update-accounts')
|
||||||
|
this.fetchTransactions()
|
||||||
|
},
|
||||||
|
|
||||||
updateSelectAll(evt) {
|
updateSelectAll(evt) {
|
||||||
this.selectedTxRaw = Object.fromEntries(this.transactions.map(tx => [tx.id, evt.target.checked]))
|
this.selectedTxRaw = Object.fromEntries(this.transactions.map(tx => [tx.id, evt.target.checked]))
|
||||||
},
|
},
|
||||||
|
|
165
frontend/components/txEditor.vue
Normal file
165
frontend/components/txEditor.vue
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
ref="date"
|
||||||
|
v-model="form.date"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="date"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
@keyup.enter="$refs.payee.focus()"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
ref="payee"
|
||||||
|
v-model="form.payee"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
@keyup.enter="$refs.category.focus()"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td v-if="account.type !== 'tracking'">
|
||||||
|
<select
|
||||||
|
ref="category"
|
||||||
|
v-model="form.category"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
@keyup.enter="$refs.description.focus()"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.id"
|
||||||
|
:value="cat.id"
|
||||||
|
>
|
||||||
|
{{ cat.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
ref="description"
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="text"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
@keyup.enter="$refs.amount.focus()"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
ref="amount"
|
||||||
|
v-model="form.amount"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
@keypress.enter="saveTransaction"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<input
|
||||||
|
v-model="form.cleared"
|
||||||
|
type="checkbox"
|
||||||
|
@keypress.enter="saveTransaction"
|
||||||
|
@keyup.esc="sendCancel"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { responseToJSON } from '../helpers'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
categories() {
|
||||||
|
const cats = this.accounts.filter(acc => acc.type === 'category')
|
||||||
|
cats.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return cats
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
amount: 0,
|
||||||
|
category: '',
|
||||||
|
cleared: false,
|
||||||
|
date: new Date().toISOString()
|
||||||
|
.split('T')[0],
|
||||||
|
|
||||||
|
description: '',
|
||||||
|
payee: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['editCancelled', 'editSaved'],
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
saveTransaction() {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
...this.form,
|
||||||
|
account: this.accountId,
|
||||||
|
category: this.form.category || null,
|
||||||
|
time: new Date(this.form.date),
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
let prom = null
|
||||||
|
if (this.edit?.id) {
|
||||||
|
prom = fetch(`/api/transactions/${this.edit.id}`, { body, headers, method: 'PUT' })
|
||||||
|
} else {
|
||||||
|
prom = fetch('/api/transactions', { body, headers, method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return prom
|
||||||
|
.then(responseToJSON)
|
||||||
|
.then(() => {
|
||||||
|
this.$emit('editSaved')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
sendCancel() {
|
||||||
|
this.$emit('editCancelled')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
if (this.edit) {
|
||||||
|
this.form = {
|
||||||
|
...this.edit,
|
||||||
|
date: new Date(this.edit.time).toISOString()
|
||||||
|
.split('T')[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.date.focus()
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AccountingAppTXEditor',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
account: {
|
||||||
|
required: true,
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
accounts: {
|
||||||
|
required: true,
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
|
||||||
|
edit: {
|
||||||
|
default: null,
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -39,5 +39,9 @@ export function responseToJSON(resp) {
|
||||||
throw new Error(`non-2xx status code: ${resp.status}`)
|
throw new Error(`non-2xx status code: ${resp.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resp.status === 204) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,9 @@ func RegisterHandler(apiRouter *mux.Router, dbc *database.Client, logger *logrus
|
||||||
apiRouter.
|
apiRouter.
|
||||||
HandleFunc("/transactions/{id}", as.handleUpdateTransaction).
|
HandleFunc("/transactions/{id}", as.handleUpdateTransaction).
|
||||||
Methods(http.MethodPatch)
|
Methods(http.MethodPatch)
|
||||||
|
apiRouter.
|
||||||
|
HandleFunc("/transactions/{id}", as.handleOverwriteTransaction).
|
||||||
|
Methods(http.MethodPut)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a apiServer) errorResponse(w http.ResponseWriter, err error, desc string, status int) {
|
func (a apiServer) errorResponse(w http.ResponseWriter, err error, desc string, status int) {
|
||||||
|
|
|
@ -123,6 +123,31 @@ func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *htt
|
||||||
a.jsonResponse(w, http.StatusOK, txs)
|
a.jsonResponse(w, http.StatusOK, txs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a apiServer) handleOverwriteTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
tx database.Transaction
|
||||||
|
txID uuid.UUID
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if txID, err = uuid.Parse(mux.Vars(r)["id"]); err != nil {
|
||||||
|
a.errorResponse(w, err, "parsing id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.NewDecoder(r.Body).Decode(&tx); err != nil {
|
||||||
|
a.errorResponse(w, err, "parsing body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = a.dbc.UpdateTransaction(txID, tx); err != nil {
|
||||||
|
a.errorResponse(w, err, "updating transaction", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (a apiServer) handleUpdateTransaction(w http.ResponseWriter, r *http.Request) {
|
func (a apiServer) handleUpdateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
txID uuid.UUID
|
txID uuid.UUID
|
||||||
|
|
|
@ -421,6 +421,33 @@ func (c *Client) UpdateAccountName(id uuid.UUID, name string) (err error) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTransaction takes a transaction, fetches the stored transaction
|
||||||
|
// applies some sanity actions and stores it back to the database
|
||||||
|
func (c *Client) UpdateTransaction(txID uuid.UUID, tx Transaction) (err error) {
|
||||||
|
if err = c.retryTx(func(db *gorm.DB) error {
|
||||||
|
var oldTX Transaction
|
||||||
|
if err := db.First(&oldTX, "id = ?", txID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return backoff.NewErrCannotRetry(fmt.Errorf("fetching old transaction: %w", err)) //nolint:wrapcheck
|
||||||
|
}
|
||||||
|
return fmt.Errorf("fetching old transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.ID = txID
|
||||||
|
tx.Account = oldTX.Account // Changing that would create chaos
|
||||||
|
|
||||||
|
if err = tx.Validate(c); err != nil {
|
||||||
|
return fmt.Errorf("validating transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Save(&tx).Error
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("updating transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateTransactionCategory modifies the category of the given
|
// UpdateTransactionCategory modifies the category of the given
|
||||||
// transaction. (It is not possible to remove a category with this)
|
// transaction. (It is not possible to remove a category with this)
|
||||||
func (c *Client) UpdateTransactionCategory(id uuid.UUID, cat uuid.UUID) (err error) {
|
func (c *Client) UpdateTransactionCategory(id uuid.UUID, cat uuid.UUID) (err error) {
|
||||||
|
|
Loading…
Reference in a new issue