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 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]))
|
||||
},
|
||||
|
|
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}`)
|
||||
}
|
||||
|
||||
if (resp.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue