From ed9b53ee79d3c0486d8eabc2256936e1cffd38fb Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 28 Jun 2026 20:26:39 -0400 Subject: [PATCH] Added actions and most of the rest of the editor context implementations. --- .../source/transaction/data_impl_sqlite.d | 3 +- .../EditTransactionPage.vue | 297 ++---------- web-app/src/pages/transaction-editor/util.ts | 426 +++++++++++++++++- web-app/src/router/index.ts | 17 +- 4 files changed, 483 insertions(+), 260 deletions(-) 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: 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'),