diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d
index 5c7791f..8445f6a 100644
--- a/finnow-api/source/transaction/data_impl_sqlite.d
+++ b/finnow-api/source/transaction/data_impl_sqlite.d
@@ -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);
diff --git a/web-app/src/pages/transaction-editor/EditTransactionPage.vue b/web-app/src/pages/transaction-editor/EditTransactionPage.vue
index 8b29697..502f6d7 100644
--- a/web-app/src/pages/transaction-editor/EditTransactionPage.vue
+++ b/web-app/src/pages/transaction-editor/EditTransactionPage.vue
@@ -12,115 +12,32 @@ The form consists of a few main sections:
-
-
+
+
@@ -335,10 +128,10 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
-
+
-
+
@@ -346,7 +139,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
-
+
+
+
+ {{ action.name }}
+
+
diff --git a/web-app/src/pages/transaction-editor/util.ts b/web-app/src/pages/transaction-editor/util.ts
index f693f4b..085e377 100644
--- a/web-app/src/pages/transaction-editor/util.ts
+++ b/web-app/src/pages/transaction-editor/util.ts
@@ -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
}
/**
* Base class for transaction editor contexts.
*/
-export abstract class TransactionEditorContextBase {}
+export interface TransactionEditorContextBase {
+ isFormDataValid(formData: TransactionEditorFormFields): boolean
+ areChangesPresent(formData: TransactionEditorFormFields): boolean
+ initializeFormFields(
+ queryParams: Record,
+ ): 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,
+ ): 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 {
+ 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)}`)
+ }
+}
diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts
index 91989d0..03ea512 100644
--- a/web-app/src/router/index.ts
+++ b/web-app/src/router/index.ts
@@ -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'),