335 lines
13 KiB
Vue
335 lines
13 KiB
Vue
<!--
|
|
This page is quite large, and handles the form in which users can create and
|
|
edit transactions. It's accessed through two routes:
|
|
- /profiles/:profileName/transactions/:transactionId for editing
|
|
- /profiles/:profileName/add-transaction for creating a new transaction
|
|
|
|
The form consists of a few main sections:
|
|
- Standard form controls for various fields like timestamp, amount, description, etc.
|
|
- Line items table for editing the list of line items.
|
|
- Tags editor for editing the set of tags.
|
|
-->
|
|
<script setup lang="ts">
|
|
import { AccountApiClient, type Account } from '@/api/account';
|
|
import { DataApiClient, type Currency } from '@/api/data';
|
|
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
|
import AppPage from '@/components/AppPage.vue';
|
|
import CategorySelect from '@/components/CategorySelect.vue';
|
|
import AppForm from '@/components/form/AppForm.vue';
|
|
import FormActions from '@/components/form/FormActions.vue';
|
|
import FormControl from '@/components/form/FormControl.vue';
|
|
import FormGroup from '@/components/form/FormGroup.vue';
|
|
import LineItemsEditor from '@/components/LineItemsEditor.vue';
|
|
import TagLabel from '@/components/TagLabel.vue';
|
|
import { useProfileStore } from '@/stores/profile-store';
|
|
import { getDatetimeLocalValueForNow } from '@/util/time';
|
|
import { computed, onMounted, ref, watch, type Ref } from 'vue';
|
|
import { useRoute, useRouter, } from 'vue-router';
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const profileStore = useProfileStore()
|
|
|
|
const existingTransaction: Ref<TransactionDetail | null> = ref(null)
|
|
const editing = computed(() => {
|
|
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
|
|
})
|
|
|
|
// General data used to populate form controls.
|
|
const allCurrencies: Ref<Currency[]> = ref([])
|
|
const availableCurrencies = computed(() => {
|
|
return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency === c.code))
|
|
})
|
|
const availableVendors: Ref<TransactionVendor[]> = ref([])
|
|
const allAccounts: Ref<Account[]> = ref([])
|
|
const availableAccounts = computed(() => {
|
|
return allAccounts.value.filter(a => a.currency === currency.value?.code)
|
|
})
|
|
const allTags: Ref<string[]> = ref([])
|
|
const availableTags = computed(() => {
|
|
return allTags.value.filter(t => !tags.value.includes(t))
|
|
})
|
|
const loading = ref(false)
|
|
|
|
// Form data:
|
|
const timestamp = ref('')
|
|
const amount = ref(0)
|
|
const currency: Ref<Currency | null> = ref(null)
|
|
const description = ref('')
|
|
const vendorId: Ref<number | null> = ref(null)
|
|
const categoryId: Ref<number | null> = ref(null)
|
|
const creditedAccountId: Ref<number | null> = ref(null)
|
|
const debitedAccountId: Ref<number | null> = ref(null)
|
|
const lineItems: Ref<TransactionDetailLineItem[]> = ref([])
|
|
const tags: Ref<string[]> = ref([])
|
|
const selectedTagToAdd: Ref<string | null> = ref(null)
|
|
const customTagInput = ref('')
|
|
const customTagInputValid = ref(false)
|
|
|
|
watch(customTagInput, (newValue: string) => {
|
|
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
|
customTagInputValid.value = result !== null && result.length > 0
|
|
})
|
|
watch(availableCurrencies, (newValue: Currency[]) => {
|
|
if (newValue.length === 1) {
|
|
currency.value = newValue[0]
|
|
}
|
|
})
|
|
|
|
onMounted(async () => {
|
|
if (!profileStore.state) return
|
|
const dataClient = new DataApiClient()
|
|
const transactionClient = new TransactionApiClient()
|
|
const accountClient = new AccountApiClient(profileStore.state)
|
|
|
|
// Fetch various collections of data needed for different user choices.
|
|
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
|
|
transactionClient.getVendors().then(vendors => availableVendors.value = vendors)
|
|
transactionClient.getAllTags().then(t => allTags.value = t)
|
|
accountClient.getAccounts().then(accounts => allAccounts.value = accounts)
|
|
|
|
|
|
const transactionIdStr = route.params.id
|
|
if (transactionIdStr && typeof (transactionIdStr) === 'string') {
|
|
const transactionId = parseInt(transactionIdStr)
|
|
try {
|
|
loading.value = true
|
|
existingTransaction.value = await transactionClient.getTransaction(transactionId)
|
|
loadValuesFromExistingTransaction(existingTransaction.value)
|
|
} catch (err) {
|
|
console.error(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
} else {
|
|
// Load default values.
|
|
timestamp.value = getDatetimeLocalValueForNow()
|
|
amount.value = Math.pow(10, currency.value?.fractionalDigits ?? 0)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Submits the transaction. If the user is editing an existing transaction,
|
|
* then that transaction will be updated. Otherwise, a new transaction is
|
|
* created.
|
|
*/
|
|
async function doSubmit() {
|
|
if (!profileStore.state) return
|
|
|
|
const localDate = new Date(timestamp.value)
|
|
const scaledAmount = amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0)
|
|
const payload: AddTransactionPayload = {
|
|
timestamp: localDate.toISOString(),
|
|
amount: scaledAmount,
|
|
currencyCode: currency.value?.code ?? '',
|
|
description: description.value,
|
|
vendorId: vendorId.value,
|
|
categoryId: categoryId.value,
|
|
creditedAccountId: creditedAccountId.value,
|
|
debitedAccountId: debitedAccountId.value,
|
|
tags: tags.value,
|
|
lineItems: lineItems.value.map(i => {
|
|
return { ...i, categoryId: i.category?.id ?? null }
|
|
})
|
|
}
|
|
|
|
const transactionApi = new TransactionApiClient()
|
|
let savedTransaction = null
|
|
try {
|
|
loading.value = true
|
|
if (existingTransaction.value) {
|
|
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
|
|
} else {
|
|
savedTransaction = await transactionApi.addTransaction(payload)
|
|
}
|
|
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
|
} catch (err) {
|
|
console.error(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels editing / submitting a transaction, and takes the user back to their
|
|
* profile's homepage.
|
|
*/
|
|
function doCancel() {
|
|
if (!profileStore.state) return
|
|
if (editing.value) {
|
|
router.replace(`/profiles/${profileStore.state.name}/transactions/${existingTransaction.value?.id}`)
|
|
} else {
|
|
router.replace(`/profiles/${profileStore.state.name}`)
|
|
}
|
|
}
|
|
|
|
function addTag() {
|
|
if (customTagInput.value.trim().length > 0) {
|
|
tags.value.push(customTagInput.value.trim())
|
|
tags.value.sort()
|
|
customTagInput.value = ''
|
|
} else if (selectedTagToAdd.value !== null) {
|
|
tags.value.push(selectedTagToAdd.value)
|
|
tags.value.sort()
|
|
selectedTagToAdd.value = null
|
|
}
|
|
}
|
|
|
|
function loadValuesFromExistingTransaction(t: TransactionDetail) {
|
|
timestamp.value = getLocalDateTimeStringFromUTCTimestamp(t.timestamp)
|
|
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
|
currency.value = t.currency
|
|
description.value = t.description
|
|
vendorId.value = t.vendor?.id ?? null
|
|
categoryId.value = t.category?.id ?? null
|
|
creditedAccountId.value = t.creditedAccount?.id ?? null
|
|
debitedAccountId.value = t.debitedAccount?.id ?? null
|
|
lineItems.value = [...t.lineItems]
|
|
tags.value = [...t.tags]
|
|
}
|
|
|
|
function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|
const date = new Date(timestamp)
|
|
date.setMilliseconds(0)
|
|
const timezoneOffset = new Date().getTimezoneOffset() * 60_000
|
|
return (new Date(date.getTime() - timezoneOffset)).toISOString().slice(0, -1)
|
|
}
|
|
|
|
/**
|
|
* Determines if the form is valid, which if true, means the user is allowed
|
|
* to save the form.
|
|
*/
|
|
function isFormValid() {
|
|
return timestamp.value.length > 0 &&
|
|
amount.value > 0 &&
|
|
currency.value !== null &&
|
|
description.value.length > 0 &&
|
|
(
|
|
creditedAccountId.value !== null ||
|
|
debitedAccountId.value !== null
|
|
) &&
|
|
creditedAccountId.value !== debitedAccountId.value
|
|
}
|
|
|
|
/**
|
|
* Determines if the user's editing an existing transaction, and there is at
|
|
* least one edit to it. Otherwise, there's no point in saving.
|
|
*/
|
|
function isEdited() {
|
|
if (!existingTransaction.value) return true
|
|
const tagsEqual = tags.value.every(t => existingTransaction.value?.tags.includes(t)) &&
|
|
existingTransaction.value.tags.every(t => tags.value.includes(t))
|
|
let lineItemsEqual = false
|
|
if (lineItems.value.length === existingTransaction.value.lineItems.length) {
|
|
lineItemsEqual = true
|
|
for (let i = 0; i < lineItems.value.length; i++) {
|
|
const i1 = lineItems.value[i]
|
|
const i2 = existingTransaction.value.lineItems[i]
|
|
if (
|
|
i1.idx !== i2.idx ||
|
|
i1.quantity !== i2.quantity ||
|
|
i1.valuePerItem !== i2.valuePerItem ||
|
|
i1.description !== i2.description ||
|
|
(i1.category?.id ?? null) !== (i2.category?.id ?? null)
|
|
) {
|
|
lineItemsEqual = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
|
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
|
currency.value !== existingTransaction.value.currency ||
|
|
description.value !== existingTransaction.value.description ||
|
|
vendorId.value !== (existingTransaction.value.vendor?.id ?? null) ||
|
|
categoryId.value !== (existingTransaction.value.category?.id ?? null) ||
|
|
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
|
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
|
!tagsEqual ||
|
|
!lineItemsEqual
|
|
}
|
|
</script>
|
|
<template>
|
|
<AppPage :title="editing ? 'Edit Transaction' : 'Add Transaction'">
|
|
<AppForm @submit="doSubmit()">
|
|
<FormGroup>
|
|
<!-- Basic properties -->
|
|
<FormControl label="Timestamp">
|
|
<input type="datetime-local" v-model="timestamp" step="1" :disabled="loading" style="min-width: 250px;" />
|
|
</FormControl>
|
|
<FormControl label="Amount">
|
|
<input type="number" v-model="amount" step="0.01" min="0.01" :disabled="loading" style="max-width: 100px;" />
|
|
</FormControl>
|
|
<FormControl label="Currency">
|
|
<select v-model="currency" :disabled="loading || availableCurrencies.length === 1">
|
|
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
|
{{ currency.code }}
|
|
</option>
|
|
</select>
|
|
</FormControl>
|
|
<FormControl label="Description" style="min-width: 200px;">
|
|
<textarea v-model="description" :disabled="loading"></textarea>
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<FormGroup>
|
|
<!-- Vendor & Category -->
|
|
<FormControl label="Vendor">
|
|
<select v-model="vendorId" :disabled="loading">
|
|
<option v-for="vendor in availableVendors" :key="vendor.id" :value="vendor.id">
|
|
{{ vendor.name }}
|
|
</option>
|
|
<option :value="null" :selected="vendorId === null">None</option>
|
|
</select>
|
|
</FormControl>
|
|
<FormControl label="Category">
|
|
<CategorySelect v-model="categoryId" />
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<FormGroup>
|
|
<!-- Accounts -->
|
|
<FormControl label="Credited Account">
|
|
<select v-model="creditedAccountId" :disabled="loading">
|
|
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
|
{{ account.name }} ({{ account.numberSuffix }})
|
|
</option>
|
|
<option :value="null">None</option>
|
|
</select>
|
|
</FormControl>
|
|
<FormControl label="Debited Account">
|
|
<select v-model="debitedAccountId" :disabled="loading">
|
|
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
|
{{ account.name }} ({{ account.numberSuffix }})
|
|
</option>
|
|
<option :value="null">None</option>
|
|
</select>
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<LineItemsEditor v-model="lineItems" :currency="currency" />
|
|
|
|
<FormGroup>
|
|
<!-- Tags -->
|
|
<FormControl label="Tags">
|
|
<div style="margin-top: 0.5rem; margin-bottom: 0.5rem;">
|
|
<TagLabel v-for="t in tags" :key="t" :tag="t" deletable @deleted="tags = tags.filter(tg => tg !== t)" />
|
|
</div>
|
|
<div>
|
|
<select v-model="selectedTagToAdd">
|
|
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
|
</select>
|
|
<input v-model="customTagInput" placeholder="Custom tag..." />
|
|
<button type="button" @click="addTag()" :disabled="selectedTagToAdd === null && !customTagInputValid">Add
|
|
Tag</button>
|
|
</div>
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
|
:submit-text="editing ? 'Save' : 'Add'" />
|
|
</AppForm>
|
|
</AppPage>
|
|
</template>
|