422 lines
14 KiB
Vue
422 lines
14 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, floatMoneyToInteger, type Currency } from '@/api/data'
|
|
import { getSelectedProfile } from '@/api/profile'
|
|
import {
|
|
TransactionApiClient,
|
|
type AddTransactionPayload,
|
|
type TransactionDetail,
|
|
type TransactionDetailLineItem,
|
|
type TransactionVendor,
|
|
} from '@/api/transaction'
|
|
import AppPage from '@/components/common/AppPage.vue'
|
|
import CategorySelect from '@/components/CategorySelect.vue'
|
|
import FileSelector from '@/components/common/FileSelector.vue'
|
|
import AppForm from '@/components/common/form/AppForm.vue'
|
|
import FormActions from '@/components/common/form/FormActions.vue'
|
|
import FormControl from '@/components/common/form/FormControl.vue'
|
|
import FormGroup from '@/components/common/form/FormGroup.vue'
|
|
import LineItemsEditor from '@/components/LineItemsEditor.vue'
|
|
import { getDatetimeLocalValueForNow } from '@/util/time'
|
|
import { computed, onMounted, ref, watch, type Ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import VendorSelect from '@/components/VendorSelect.vue'
|
|
import TagsSelect from '@/components/TagsSelect.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
|
const accountApi = new AccountApiClient(route)
|
|
|
|
const existingTransaction: Ref<TransactionDetail | null> = ref(null)
|
|
const editing = computed(() => {
|
|
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
|
|
})
|
|
const formValid = computed(() => {
|
|
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
|
|
)
|
|
})
|
|
const unsavedEdits = computed(() => {
|
|
console.log('Checking if there are unsaved edits...')
|
|
if (!existingTransaction.value) return true
|
|
const tx = existingTransaction.value
|
|
const tagsEqual =
|
|
tags.value.every((t) => tx.tags.includes(t)) && tx.tags.every((t) => tags.value.includes(t))
|
|
let lineItemsEqual = false
|
|
if (lineItems.value.length === tx.lineItems.length) {
|
|
lineItemsEqual = true
|
|
for (let i = 0; i < lineItems.value.length; i++) {
|
|
const i1 = lineItems.value[i]
|
|
const i2 = tx.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
|
|
}
|
|
}
|
|
}
|
|
const attachmentsChanged =
|
|
attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
|
|
const timestampChanged = new Date(timestamp.value).toISOString() !== tx.timestamp
|
|
const amountChanged =
|
|
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount
|
|
const currencyChanged = currency.value?.code !== tx.currency.code
|
|
const descriptionChanged = description.value !== tx.description
|
|
const internalTransferChanged = internalTransfer.value !== tx.internalTransfer
|
|
const vendorChanged = vendor.value?.id !== tx.vendor?.id
|
|
const categoryChanged = categoryId.value !== (tx.category?.id ?? null)
|
|
const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null)
|
|
const debitedAccountChanged = debitedAccountId.value !== (tx.debitedAccount?.id ?? null)
|
|
console.log(`
|
|
Timestamp changed: ${timestampChanged}
|
|
Amount changed: ${amountChanged}
|
|
Currency changed: ${currencyChanged}
|
|
Description changed: ${descriptionChanged}
|
|
Internal Transfer changed: ${internalTransferChanged}
|
|
Vendor changed: ${vendorChanged}
|
|
Category changed: ${categoryChanged}
|
|
Credited account changed: ${creditedAccountChanged}
|
|
Debited account changed: ${debitedAccountChanged}
|
|
Tags changed: ${!tagsEqual}
|
|
Line items changed: ${!lineItemsEqual}
|
|
Attachments changed: ${attachmentsChanged}
|
|
`)
|
|
|
|
return (
|
|
timestampChanged ||
|
|
amountChanged ||
|
|
currencyChanged ||
|
|
descriptionChanged ||
|
|
internalTransferChanged ||
|
|
vendorChanged ||
|
|
categoryChanged ||
|
|
creditedAccountChanged ||
|
|
debitedAccountChanged ||
|
|
!tagsEqual ||
|
|
!lineItemsEqual ||
|
|
attachmentsChanged
|
|
)
|
|
})
|
|
|
|
// 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.code === c.code),
|
|
)
|
|
})
|
|
const allAccounts: Ref<Account[]> = ref([])
|
|
const availableAccounts = computed(() => {
|
|
return allAccounts.value.filter((a) => a.currency.code === currency.value?.code)
|
|
})
|
|
const loading = ref(false)
|
|
|
|
// Form data:
|
|
const timestamp = ref('')
|
|
const amount = ref(0)
|
|
const currency: Ref<Currency | null> = ref(null)
|
|
const description = ref('')
|
|
const internalTransfer = ref(false)
|
|
const vendor: Ref<TransactionVendor | 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 customTagInput = ref('')
|
|
const customTagInputValid = ref(false)
|
|
const attachmentsToUpload: Ref<File[]> = ref([])
|
|
const removedAttachmentIds: Ref<number[]> = ref([])
|
|
|
|
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 () => {
|
|
const dataClient = new DataApiClient()
|
|
|
|
// Fetch various collections of data needed for different user choices.
|
|
dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
|
|
accountApi.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 transactionApi.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 (currency.value === null) {
|
|
return
|
|
}
|
|
|
|
let vendorId: number | null = vendor.value?.id ?? null
|
|
if (vendor.value !== null && vendorId === -1) {
|
|
const newVendor = await transactionApi.createVendor({
|
|
name: vendor.value?.name,
|
|
description: null,
|
|
})
|
|
vendorId = newVendor.id
|
|
}
|
|
|
|
const localDate = new Date(timestamp.value)
|
|
const payload: AddTransactionPayload = {
|
|
timestamp: localDate.toISOString(),
|
|
amount: floatMoneyToInteger(amount.value, currency.value),
|
|
currencyCode: currency.value?.code ?? '',
|
|
description: description.value,
|
|
internalTransfer: internalTransfer.value,
|
|
vendorId: vendorId,
|
|
categoryId: categoryId.value,
|
|
creditedAccountId: creditedAccountId.value,
|
|
debitedAccountId: debitedAccountId.value,
|
|
tags: tags.value,
|
|
lineItems: lineItems.value.map((i) => {
|
|
return { ...i, categoryId: i.category?.id ?? null }
|
|
}),
|
|
attachmentIdsToRemove: removedAttachmentIds.value,
|
|
}
|
|
|
|
let savedTransaction = null
|
|
try {
|
|
loading.value = true
|
|
if (existingTransaction.value) {
|
|
savedTransaction = await transactionApi.updateTransaction(
|
|
existingTransaction.value?.id,
|
|
payload,
|
|
attachmentsToUpload.value,
|
|
)
|
|
} else {
|
|
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
|
|
}
|
|
await router.replace(
|
|
`/profiles/${getSelectedProfile(route)}/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 (editing.value) {
|
|
router.replace(
|
|
`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`,
|
|
)
|
|
} else {
|
|
router.replace(`/profiles/${getSelectedProfile(route)}`)
|
|
}
|
|
}
|
|
|
|
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
|
|
internalTransfer.value = t.internalTransfer
|
|
vendor.value = t.vendor ?? 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)
|
|
}
|
|
</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">
|
|
<VendorSelect v-model="vendor" />
|
|
</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-if="currency"
|
|
v-model="lineItems"
|
|
:currency="currency"
|
|
:transaction-amount="floatMoneyToInteger(amount, currency)"
|
|
/>
|
|
|
|
<FormGroup>
|
|
<!-- Tags -->
|
|
<FormControl label="Tags">
|
|
<TagsSelect v-model="tags" />
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<FormGroup>
|
|
<h5>Attachments</h5>
|
|
<FileSelector
|
|
:initial-files="existingTransaction?.attachments ?? []"
|
|
v-model:uploaded-files="attachmentsToUpload"
|
|
v-model:removed-files="removedAttachmentIds"
|
|
/>
|
|
</FormGroup>
|
|
|
|
<!-- One last group for less-often used fields: -->
|
|
<FormGroup>
|
|
<FormControl
|
|
label="Internal Transfer"
|
|
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
v-model="internalTransfer"
|
|
:disabled="loading"
|
|
/>
|
|
</FormControl>
|
|
</FormGroup>
|
|
|
|
<FormActions
|
|
@cancel="doCancel()"
|
|
:disabled="loading || !formValid || !unsavedEdits"
|
|
:submit-text="editing ? 'Save' : 'Add'"
|
|
/>
|
|
</AppForm>
|
|
</AppPage>
|
|
</template>
|