WIP: Add Drafts, Templates, and Recurring Transactions #45
|
|
@ -703,7 +703,8 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
|||
));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
},
|
||||
draft.id
|
||||
);
|
||||
// Return the response, excluding attachments (they are fetched using the attachment repo).
|
||||
return Optional!TransactionDraftResponse.of(response);
|
||||
|
|
|
|||
|
|
@ -12,115 +12,32 @@ The form consists of a few main sections:
|
|||
<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 TransactionLineItemResponse,
|
||||
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'
|
||||
import {
|
||||
defaultEmptyFormFields,
|
||||
loadEditorContextFromRoute,
|
||||
NewTransactionEditorContext,
|
||||
type TransactionEditorContextBase,
|
||||
type TransactionEditorFormFields,
|
||||
} from './util'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import AppButton from '@/components/common/AppButton.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(() => {
|
||||
|
|
@ -130,28 +47,17 @@ const availableCurrencies = computed(() => {
|
|||
})
|
||||
const allAccounts: Ref<Account[]> = ref([])
|
||||
const availableAccounts = computed(() => {
|
||||
return allAccounts.value.filter((a) => a.currency.code === currency.value?.code)
|
||||
return allAccounts.value.filter((a) => a.currency.code === formData.value.currency?.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<TransactionLineItemResponse[]> = ref([])
|
||||
const tags: Ref<string[]> = ref([])
|
||||
const attachmentsToUpload: Ref<File[]> = ref([])
|
||||
const removedAttachmentIds: Ref<number[]> = ref([])
|
||||
// Reactive form data:
|
||||
const loading = ref(false)
|
||||
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
|
||||
const editorContext: Ref<TransactionEditorContextBase> = ref(new NewTransactionEditorContext())
|
||||
|
||||
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||
if (newValue.length === 1) {
|
||||
currency.value = newValue[0]
|
||||
formData.value.currency = newValue[0]
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -162,136 +68,23 @@ onMounted(async () => {
|
|||
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)
|
||||
editorContext.value = await loadEditorContextFromRoute(route)
|
||||
} 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)
|
||||
}
|
||||
|
||||
// Load default values from the query parameters.
|
||||
if ('credited-account' in route.query) {
|
||||
creditedAccountId.value = parseInt(route.query['credited-account'] as string)
|
||||
}
|
||||
if ('debited-account' in route.query) {
|
||||
debitedAccountId.value = parseInt(route.query['debited-account'] as string)
|
||||
}
|
||||
formData.value = editorContext.value.initializeFormFields(route.query)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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()">
|
||||
<AppPage :title="false ? 'Edit Transaction' : 'Add Transaction'">
|
||||
<AppForm>
|
||||
<FormGroup>
|
||||
<!-- Basic properties -->
|
||||
<FormControl label="Timestamp">
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="timestamp"
|
||||
v-model="formData.timestamp"
|
||||
step="1"
|
||||
:disabled="loading"
|
||||
style="min-width: 250px"
|
||||
|
|
@ -300,7 +93,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
<FormControl label="Amount">
|
||||
<input
|
||||
type="number"
|
||||
v-model="amount"
|
||||
v-model="formData.amount"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
:disabled="loading"
|
||||
|
|
@ -309,7 +102,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
</FormControl>
|
||||
<FormControl label="Currency">
|
||||
<select
|
||||
v-model="currency"
|
||||
v-model="formData.currency"
|
||||
:disabled="loading || availableCurrencies.length === 1"
|
||||
>
|
||||
<option
|
||||
|
|
@ -326,7 +119,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
style="min-width: 200px"
|
||||
>
|
||||
<textarea
|
||||
v-model="description"
|
||||
v-model="formData.description"
|
||||
:disabled="loading"
|
||||
></textarea>
|
||||
</FormControl>
|
||||
|
|
@ -335,10 +128,10 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
<FormGroup>
|
||||
<!-- Vendor & Category -->
|
||||
<FormControl label="Vendor">
|
||||
<VendorSelect v-model="vendor" />
|
||||
<VendorSelect v-model="formData.vendor" />
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<CategorySelect v-model="categoryId" />
|
||||
<CategorySelect v-model="formData.categoryId" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
|
|
@ -346,7 +139,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
<!-- Accounts -->
|
||||
<FormControl label="Credited Account">
|
||||
<select
|
||||
v-model="creditedAccountId"
|
||||
v-model="formData.creditedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
|
|
@ -361,7 +154,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
</FormControl>
|
||||
<FormControl label="Debited Account">
|
||||
<select
|
||||
v-model="debitedAccountId"
|
||||
v-model="formData.debitedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
|
|
@ -377,25 +170,25 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
</FormGroup>
|
||||
|
||||
<LineItemsEditor
|
||||
v-if="currency"
|
||||
v-model="lineItems"
|
||||
:currency="currency"
|
||||
:transaction-amount="floatMoneyToInteger(amount, currency)"
|
||||
v-if="formData.currency"
|
||||
v-model="formData.lineItems"
|
||||
:currency="formData.currency"
|
||||
:transaction-amount="floatMoneyToInteger(formData.amount ?? 0, formData.currency)"
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Tags -->
|
||||
<FormControl label="Tags">
|
||||
<TagsSelect v-model="tags" />
|
||||
<TagsSelect v-model="formData.tags" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<h5>Attachments</h5>
|
||||
<FileSelector
|
||||
:initial-files="existingTransaction?.attachments ?? []"
|
||||
v-model:uploaded-files="attachmentsToUpload"
|
||||
v-model:removed-files="removedAttachmentIds"
|
||||
:initial-files="formData.existingAttachments"
|
||||
v-model:uploaded-files="formData.attachmentsToUpload"
|
||||
v-model:removed-files="formData.removedAttachmentIds"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
|
|
@ -407,17 +200,23 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="internalTransfer"
|
||||
v-model="formData.internalTransfer"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions
|
||||
@cancel="doCancel()"
|
||||
:disabled="loading || !formValid || !unsavedEdits"
|
||||
:submit-text="editing ? 'Save' : 'Add'"
|
||||
/>
|
||||
<!-- The set of available actions is defined by the current editor context. -->
|
||||
<ButtonBar>
|
||||
<AppButton
|
||||
v-for="action in editorContext.getActions(formData)"
|
||||
:key="action.name"
|
||||
:disabled="action.disabled"
|
||||
@click="action.callback(formData, route, router)"
|
||||
>
|
||||
{{ action.name }}
|
||||
</AppButton>
|
||||
</ButtonBar>
|
||||
</AppForm>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import type { Currency } from '@/api/data'
|
||||
import type { TransactionLineItemResponse, TransactionVendor } from '@/api/transaction'
|
||||
import type { Attachment } from '@/api/attachment'
|
||||
import { floatMoneyToInteger, integerMoneyToFloat, type Currency } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type AddTransactionPayload,
|
||||
type TransactionDetail,
|
||||
type TransactionDraftPayload,
|
||||
type TransactionDraftResponse,
|
||||
type TransactionLineItemResponse,
|
||||
type TransactionVendor,
|
||||
} from '@/api/transaction'
|
||||
import { getDatetimeLocalValueForNow } from '@/util/time'
|
||||
import type { RouteLocation, Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* The set of all form fields on the transaction editor page. Note that some
|
||||
|
|
@ -20,13 +32,417 @@ export interface TransactionEditorFormFields {
|
|||
tags: string[]
|
||||
attachmentsToUpload: File[]
|
||||
removedAttachmentIds: number[]
|
||||
existingAttachments: Attachment[]
|
||||
}
|
||||
|
||||
export function defaultEmptyFormFields(): TransactionEditorFormFields {
|
||||
return {
|
||||
timestamp: null,
|
||||
amount: null,
|
||||
templateName: null,
|
||||
currency: null,
|
||||
description: null,
|
||||
internalTransfer: null,
|
||||
vendor: null,
|
||||
categoryId: null,
|
||||
creditedAccountId: null,
|
||||
debitedAccountId: null,
|
||||
lineItems: [],
|
||||
tags: [],
|
||||
attachmentsToUpload: [],
|
||||
removedAttachmentIds: [],
|
||||
existingAttachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransactionEditorAction {
|
||||
name: string
|
||||
disabled: boolean
|
||||
callback: (
|
||||
formData: TransactionEditorFormFields,
|
||||
route: RouteLocation,
|
||||
router: Router,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for transaction editor contexts.
|
||||
*/
|
||||
export abstract class TransactionEditorContextBase {}
|
||||
export interface TransactionEditorContextBase {
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean
|
||||
initializeFormFields(
|
||||
queryParams: Record<string, string | null | (string | null)[]>,
|
||||
): TransactionEditorFormFields
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[]
|
||||
}
|
||||
|
||||
export class TransactionEditorContext extends TransactionEditorContextBase {}
|
||||
/**
|
||||
* Editor context that's used when the user starts editing a new transaction,
|
||||
* not related to any saved transaction or draft.
|
||||
*/
|
||||
export class NewTransactionEditorContext implements TransactionEditorContextBase {
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.amount !== null &&
|
||||
formData.amount > 0 &&
|
||||
formData.timestamp !== null &&
|
||||
formData.currency !== null
|
||||
)
|
||||
}
|
||||
|
||||
export class DraftEditorContext extends TransactionEditorContextBase {}
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.timestamp !== null ||
|
||||
formData.amount !== null ||
|
||||
formData.templateName !== null ||
|
||||
formData.currency !== null ||
|
||||
formData.description !== null ||
|
||||
formData.internalTransfer !== null ||
|
||||
formData.vendor !== null ||
|
||||
formData.creditedAccountId !== null ||
|
||||
formData.debitedAccountId !== null ||
|
||||
formData.lineItems.length > 0 ||
|
||||
formData.tags.length > 0 ||
|
||||
formData.attachmentsToUpload.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
initializeFormFields(
|
||||
queryParams: Record<string, string | null | (string | null)[]>,
|
||||
): TransactionEditorFormFields {
|
||||
const fields = defaultEmptyFormFields()
|
||||
fields.timestamp = getDatetimeLocalValueForNow()
|
||||
if ('credited-account' in queryParams) {
|
||||
fields.creditedAccountId = parseInt(queryParams['credited-account'] as string)
|
||||
}
|
||||
if ('debited-account' in queryParams) {
|
||||
fields.debitedAccountId = parseInt(queryParams['debited-account'] as string)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save',
|
||||
disabled: !(this.areChangesPresent(formData) && this.isFormDataValid(formData)),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
// Assume that form data is valid!
|
||||
const data = toTransactionPayload(formData)
|
||||
const txn = await api.addTransaction(data, formData.attachmentsToUpload)
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Save Draft',
|
||||
disabled: !this.areChangesPresent(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
const data = toDraftPayload(formData)
|
||||
const draft = await api.addDraft(data, formData.attachmentsToUpload)
|
||||
await router.replace(
|
||||
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context for when the user is editing a transaction.
|
||||
*/
|
||||
export class TransactionEditorContext implements TransactionEditorContextBase {
|
||||
private existingTransaction: TransactionDetail
|
||||
|
||||
constructor(existingTransaction: TransactionDetail) {
|
||||
this.existingTransaction = existingTransaction
|
||||
}
|
||||
|
||||
isFormDataValid(formData: TransactionEditorFormFields): boolean {
|
||||
return (
|
||||
formData.timestamp !== null &&
|
||||
formData.timestamp.length > 0 &&
|
||||
formData.amount !== null &&
|
||||
formData.amount > 0 &&
|
||||
formData.currency !== null &&
|
||||
formData.description !== null &&
|
||||
formData.description.length > 0 &&
|
||||
(formData.creditedAccountId !== null || formData.debitedAccountId !== null) &&
|
||||
formData.creditedAccountId !== formData.debitedAccountId
|
||||
)
|
||||
}
|
||||
|
||||
areChangesPresent(formData: TransactionEditorFormFields): boolean {
|
||||
const tx: TransactionDetail = this.existingTransaction
|
||||
const tagsChanged =
|
||||
formData.tags.every((t) => tx.tags.includes(t)) &&
|
||||
tx.tags.every((t) => formData.tags.includes(t))
|
||||
const lineItemsChanged =
|
||||
JSON.stringify(formData.lineItems) !== JSON.stringify(formData.lineItems)
|
||||
const attachmentsChanged =
|
||||
formData.attachmentsToUpload.length > 0 || formData.removedAttachmentIds.length > 0
|
||||
|
||||
const timestampChanged = new Date(formData.timestamp ?? 0).toISOString() !== tx.timestamp
|
||||
const amountChanged =
|
||||
floatMoneyToInteger(formData.amount ?? 0, formData.currency ?? tx.currency) !== tx.amount
|
||||
const currencyChanged = formData.currency?.code !== tx.currency.code
|
||||
const descriptionChanged = formData.description !== tx.description
|
||||
const internalTransferChanged = formData.internalTransfer !== tx.internalTransfer
|
||||
const vendorChanged = formData.vendor?.id !== tx.vendor?.id
|
||||
const categoryChanged = formData.categoryId !== (tx.category?.id ?? null)
|
||||
const creditedAccountChanged = formData.creditedAccountId !== (tx.creditedAccount?.id ?? null)
|
||||
const debitedAccountChanged = formData.debitedAccountId !== (tx.debitedAccount?.id ?? null)
|
||||
|
||||
return (
|
||||
tagsChanged ||
|
||||
lineItemsChanged ||
|
||||
attachmentsChanged ||
|
||||
timestampChanged ||
|
||||
amountChanged ||
|
||||
currencyChanged ||
|
||||
descriptionChanged ||
|
||||
internalTransferChanged ||
|
||||
vendorChanged ||
|
||||
categoryChanged ||
|
||||
creditedAccountChanged ||
|
||||
debitedAccountChanged
|
||||
)
|
||||
}
|
||||
|
||||
initializeFormFields(): TransactionEditorFormFields {
|
||||
const tx = this.existingTransaction
|
||||
return {
|
||||
timestamp: getLocalDateTimeStringFromUTCTimestamp(tx.timestamp),
|
||||
amount: integerMoneyToFloat(tx.amount, tx.currency),
|
||||
templateName: null,
|
||||
currency: tx.currency,
|
||||
description: tx.description,
|
||||
internalTransfer: tx.internalTransfer,
|
||||
vendor: tx.vendor ?? null,
|
||||
categoryId: tx.category?.id ?? null,
|
||||
creditedAccountId: tx.creditedAccount?.id ?? null,
|
||||
debitedAccountId: tx.debitedAccount?.id ?? null,
|
||||
lineItems: [...tx.lineItems],
|
||||
tags: [...tx.tags],
|
||||
attachmentsToUpload: [],
|
||||
removedAttachmentIds: [],
|
||||
existingAttachments: [...tx.attachments],
|
||||
}
|
||||
}
|
||||
|
||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save',
|
||||
disabled: !this.areChangesPresent(formData) || !this.isFormDataValid(formData),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
// Assume that form data is valid!
|
||||
const data = toTransactionPayload(formData)
|
||||
const txn = await api.updateTransaction(
|
||||
this.existingTransaction.id,
|
||||
data,
|
||||
formData.attachmentsToUpload,
|
||||
)
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context for when the user is editing an existing draft.
|
||||
*/
|
||||
export class DraftEditorContext implements TransactionEditorContextBase {
|
||||
private existingDraft: TransactionDraftResponse
|
||||
|
||||
constructor(existingDraft: TransactionDraftResponse) {
|
||||
this.existingDraft = existingDraft
|
||||
}
|
||||
|
||||
isFormDataValid(): boolean {
|
||||
// TODO: What validation is needed client-side for draft data?
|
||||
return true
|
||||
}
|
||||
|
||||
areChangesPresent(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
initializeFormFields(): TransactionEditorFormFields {
|
||||
const d = this.existingDraft
|
||||
const fields = defaultEmptyFormFields()
|
||||
if (d.timestamp !== null) {
|
||||
fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp)
|
||||
}
|
||||
if (d.amount !== null && d.currency !== null) {
|
||||
fields.currency = d.currency
|
||||
fields.amount = integerMoneyToFloat(d.amount, d.currency)
|
||||
}
|
||||
fields.description = d.description
|
||||
fields.internalTransfer = d.internalTransfer
|
||||
if (d.vendor !== null) {
|
||||
fields.vendor = {
|
||||
id: d.vendor.id,
|
||||
name: d.vendor.name,
|
||||
description: '',
|
||||
}
|
||||
// TODO: Update TransactionDraftResponse format to include full vendor data.
|
||||
}
|
||||
if (d.category !== null) {
|
||||
fields.categoryId = d.category.id
|
||||
}
|
||||
if (d.creditedAccount !== null) {
|
||||
fields.creditedAccountId = d.creditedAccount.id
|
||||
}
|
||||
if (d.debitedAccount !== null) {
|
||||
fields.debitedAccountId = d.debitedAccount.id
|
||||
}
|
||||
fields.lineItems = [...d.lineItems]
|
||||
fields.tags = [...d.tags]
|
||||
fields.existingAttachments = [...d.attachments]
|
||||
return fields
|
||||
}
|
||||
|
||||
getActions(): TransactionEditorAction[] {
|
||||
return [
|
||||
{
|
||||
name: 'Save',
|
||||
disabled: !this.areChangesPresent() || !this.isFormDataValid(),
|
||||
callback: async (formData, route, router) => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
const data = toDraftPayload(formData)
|
||||
const draft = await api.updateDraft(
|
||||
this.existingDraft.id,
|
||||
data,
|
||||
formData.attachmentsToUpload,
|
||||
)
|
||||
await router.replace(
|
||||
`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
disabled: false,
|
||||
callback: async (_formData, route, router) => {
|
||||
await goBackOrHome(router, route)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains an editor context by determining what the user is doing based on the
|
||||
* route they've navigated to.
|
||||
* @param route The current route (which tells us what intent the user has).
|
||||
* @returns A promise that resolves to an editor context.
|
||||
*/
|
||||
export async function loadEditorContextFromRoute(
|
||||
route: RouteLocation,
|
||||
): Promise<TransactionEditorContextBase> {
|
||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
||||
if (route.name === 'edit-transaction') {
|
||||
const transactionIdStr = route.params.id
|
||||
if (transactionIdStr && typeof transactionIdStr === 'string') {
|
||||
const transactionId = parseInt(transactionIdStr)
|
||||
const existingTransaction = await transactionApi.getTransaction(transactionId)
|
||||
return new TransactionEditorContext(existingTransaction)
|
||||
}
|
||||
} else if (route.name === 'edit-draft') {
|
||||
const draftIdStr = route.params.id
|
||||
if (draftIdStr && typeof draftIdStr === 'string') {
|
||||
const draftId = parseInt(draftIdStr)
|
||||
const existingDraft = await transactionApi.getDraft(draftId)
|
||||
return new DraftEditorContext(existingDraft)
|
||||
}
|
||||
}
|
||||
|
||||
return new NewTransactionEditorContext()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraftPayload {
|
||||
if (typeof formData.amount === 'string') {
|
||||
formData.amount = null
|
||||
}
|
||||
let isoTimestamp = null
|
||||
if (formData.timestamp !== null && formData.timestamp.length > 0) {
|
||||
isoTimestamp = new Date(formData.timestamp).toISOString()
|
||||
}
|
||||
return {
|
||||
templateName: formData.templateName,
|
||||
timestamp: isoTimestamp,
|
||||
amount: formData.amount,
|
||||
currencyCode: formData.currency?.code ?? null,
|
||||
description: formData.description,
|
||||
internalTransfer: formData.internalTransfer,
|
||||
vendorId: formData.vendor?.id ?? null,
|
||||
categoryId: formData.categoryId,
|
||||
creditedAccountId: formData.creditedAccountId,
|
||||
debitedAccountId: formData.debitedAccountId,
|
||||
tags: [...formData.tags],
|
||||
lineItems: [...formData.lineItems].map((li) => {
|
||||
return {
|
||||
valuePerItem: li.valuePerItem,
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
categoryId: li.category?.id ?? null,
|
||||
}
|
||||
}),
|
||||
attachmentIdsToRemove: [...formData.removedAttachmentIds],
|
||||
}
|
||||
}
|
||||
|
||||
function toTransactionPayload(formData: TransactionEditorFormFields): AddTransactionPayload {
|
||||
const payload: AddTransactionPayload = {
|
||||
timestamp: new Date(formData.timestamp!).toISOString(),
|
||||
amount: floatMoneyToInteger(formData.amount!, formData.currency!),
|
||||
currencyCode: formData.currency!.code,
|
||||
description: formData.description ?? '',
|
||||
internalTransfer: formData.internalTransfer ?? false,
|
||||
vendorId: formData.vendor?.id ?? null,
|
||||
categoryId: formData.categoryId,
|
||||
creditedAccountId: formData.creditedAccountId,
|
||||
debitedAccountId: formData.debitedAccountId,
|
||||
tags: formData.tags,
|
||||
lineItems: formData.lineItems.map((li) => {
|
||||
return { ...li, categoryId: li.category?.id ?? null }
|
||||
}),
|
||||
attachmentIdsToRemove: formData.removedAttachmentIds,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function goBackOrHome(router: Router, route: RouteLocation) {
|
||||
if (window.history.length > 0) {
|
||||
await router.back()
|
||||
} else {
|
||||
await router.replace(`/profiles/${getSelectedProfile(route)}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,20 +63,27 @@ const router = createRouter({
|
|||
meta: { title: 'Transaction' },
|
||||
},
|
||||
{
|
||||
name: 'edit-transaction',
|
||||
path: 'transactions/:id/edit',
|
||||
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||
meta: { title: 'Edit Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'transactions/search',
|
||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||
meta: { title: 'Search Transactions' },
|
||||
},
|
||||
{
|
||||
path: 'add-transaction',
|
||||
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||
meta: { title: 'Add Transaction' },
|
||||
},
|
||||
{
|
||||
name: 'edit-draft',
|
||||
path: 'transaction-drafts/:id/edit',
|
||||
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
||||
meta: { title: 'Edit Draft' },
|
||||
},
|
||||
{
|
||||
path: 'transactions/search',
|
||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||
meta: { title: 'Search Transactions' },
|
||||
},
|
||||
{
|
||||
path: 'vendors',
|
||||
component: () => import('@/pages/VendorsPage.vue'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue