Allow to edit transactions

improve keyboard usability of transaction editor

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-01-20 22:30:29 +01:00
parent 9934d49b7c
commit 2c38247221
Signed by: luzifer
SSH Key Fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
6 changed files with 322 additions and 133 deletions

View File

@ -8,7 +8,7 @@
/>
</div>
<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-inline-flex text-center flex-column mx-4">
{{ formatNumber(account.balance - balanceUncleared) }}
@ -35,28 +35,49 @@
<div class="col d-flex align-items-center justify-content-end">
<div class="btn-group btn-group-sm">
<button
class="btn"
class="btn btn-primary"
@click="showAddTransaction = !showAddTransaction"
>
<i class="fas fa-fw fa-plus-circle mr-1" />
Add Transaction
</button>
<button
class="btn"
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>
<button
class="btn text-danger"
:disabled="selectedTx.length < 1"
@click="deleteSelected"
class="btn btn-secondary"
:disabled="selectedTx.length !== 1"
@click="editSelected"
>
<i class="fas fa-fw fa-trash mr-1" />
Delete Selected
<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"
@click="deleteSelected"
>
<i class="fas fa-fw fa-trash mr-1" />
Delete Selected
</button>
</li>
</ul>
</div>
</div>
</div>
@ -88,91 +109,59 @@
</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 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
<tx-editor
v-if="showAddTransaction"
:account="account"
:accounts="accounts"
@editCancelled="showAddTransaction = false"
@editSaved="txSaved"
/>
<template
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 v-if="account.type !== 'tracking'">
{{ accountIdToName[tx.category] }}
</td>
<td>{{ tx.description }}</td>
<td :class="{'minimized-amount 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>
<tr
v-if="tx.id !== editedTxId"
:key="tx.id"
@dblclick="editTx(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 v-if="account.type !== 'tracking'">
{{ accountIdToName[tx.category] }}
</td>
<td>{{ tx.description }}</td>
<td :class="{'minimized-amount 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>
<tx-editor
v-else
:key="tx.id"
:account="account"
:accounts="accounts"
:edit="tx"
@editCancelled="editedTxId = null"
@editSaved="txSaved"
/>
</template>
</tbody>
</table>
</div>
@ -304,11 +293,12 @@
/* eslint-disable sort-imports */
import { Modal } from 'bootstrap'
import { formatNumber, responseToJSON } from '../helpers'
import { formatNumber } from '../helpers'
import rangeSelector from './rangeSelector.vue'
import txEditor from './txEditor.vue'
export default {
components: { rangeSelector },
components: { rangeSelector, txEditor },
computed: {
account() {
@ -382,16 +372,7 @@ export default {
data() {
return {
form: {
amount: 0,
category: '',
cleared: false,
date: new Date().toISOString()
.split('T')[0],
description: '',
payee: '',
},
editedTxId: null,
modals: {
createTransfer: {
@ -410,36 +391,6 @@ export default {
},
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() {
const actions = []
for (const id of this.selectedTx) {
@ -455,6 +406,14 @@ export default {
})
},
editSelected() {
this.editTx(this.selectedTx[0])
},
editTx(txId) {
this.editedTxId = txId
},
fetchTransactions() {
const since = this.timeRange.start.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) {
this.selectedTxRaw = Object.fromEntries(this.transactions.map(tx => [tx.id, evt.target.checked]))
},

View 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>

View File

@ -39,5 +39,9 @@ export function responseToJSON(resp) {
throw new Error(`non-2xx status code: ${resp.status}`)
}
if (resp.status === 204) {
return null
}
return resp.json()
}

View File

@ -61,6 +61,9 @@ func RegisterHandler(apiRouter *mux.Router, dbc *database.Client, logger *logrus
apiRouter.
HandleFunc("/transactions/{id}", as.handleUpdateTransaction).
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) {

View File

@ -123,6 +123,31 @@ func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *htt
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) {
var (
txID uuid.UUID

View File

@ -421,6 +421,33 @@ func (c *Client) UpdateAccountName(id uuid.UUID, name string) (err error) {
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
// transaction. (It is not possible to remove a category with this)
func (c *Client) UpdateTransactionCategory(id uuid.UUID, cat uuid.UUID) (err error) {