Compare commits
No commits in common. "23cfe0b1a976b270fe27325285284dfd3d2e6f96" and "d0e8b9ab4bbc7862d15fc5a141bee0a18df4b456" have entirely different histories.
23cfe0b1a9
...
d0e8b9ab4b
|
|
@ -682,7 +682,6 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
response.category = draft.category;
|
response.category = draft.category;
|
||||||
response.creditedAccount = draft.creditedAccount;
|
response.creditedAccount = draft.creditedAccount;
|
||||||
response.debitedAccount = draft.debitedAccount;
|
response.debitedAccount = draft.debitedAccount;
|
||||||
response.tags = li.value.tags;
|
|
||||||
response.lineItems = util.sqlite.findAll(
|
response.lineItems = util.sqlite.findAll(
|
||||||
db,
|
db,
|
||||||
import("sql/query/get_line_items_draft.sql"),
|
import("sql/query/get_line_items_draft.sql"),
|
||||||
|
|
@ -704,8 +703,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
},
|
}
|
||||||
draft.id
|
|
||||||
);
|
);
|
||||||
// Return the response, excluding attachments (they are fetched using the attachment repo).
|
// Return the response, excluding attachments (they are fetched using the attachment repo).
|
||||||
return Optional!TransactionDraftResponse.of(response);
|
return Optional!TransactionDraftResponse.of(response);
|
||||||
|
|
@ -882,7 +880,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
row.peek!string(20)
|
row.peek!string(20)
|
||||||
).toOptional;
|
).toOptional;
|
||||||
}
|
}
|
||||||
string aggregateTags = row.peek!string(21);
|
string aggregateTags = row.peek!(string, PeekMode.slice)(21);
|
||||||
if (aggregateTags !is null) {
|
if (aggregateTags !is null) {
|
||||||
import std.string : split;
|
import std.string : split;
|
||||||
item.tags = aggregateTags.split(",");
|
item.tags = aggregateTags.split(",");
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { formatMoney } from '@/api/data'
|
|
||||||
import { getSelectedProfile } from '@/api/profile'
|
|
||||||
import type { TransactionDraftListItem } from '@/api/transaction'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import AppBadge from './common/AppBadge.vue'
|
|
||||||
import CategoryLabel from './CategoryLabel.vue'
|
|
||||||
import TagLabel from './TagLabel.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const props = defineProps<{ draft: TransactionDraftListItem }>()
|
|
||||||
|
|
||||||
function goToDraft() {
|
|
||||||
const profile = getSelectedProfile(route)
|
|
||||||
router.push(`/profiles/${profile}/transaction-drafts/${props.draft.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="transaction-draft-card"
|
|
||||||
@click="goToDraft()"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<!-- Top row contains timestamp and amount. -->
|
|
||||||
<div style="display: flex; justify-content: space-between">
|
|
||||||
<div>
|
|
||||||
<div class="font-mono font-size-xsmall text-normal">Draft #{{ draft.id }}</div>
|
|
||||||
<div
|
|
||||||
class="text-muted font-mono font-size-xsmall"
|
|
||||||
v-if="draft.timestamp"
|
|
||||||
>
|
|
||||||
{{ new Date(draft.timestamp).toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="font-mono align-right font-size-small"
|
|
||||||
v-if="draft.amount && draft.currency"
|
|
||||||
>
|
|
||||||
{{ formatMoney(draft.amount, draft.currency) }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="draft.creditedAccount !== null"
|
|
||||||
class="font-size-small text-muted"
|
|
||||||
>
|
|
||||||
Credited to <span class="text-normal font-bold">{{ draft.creditedAccount.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="draft.debitedAccount !== null"
|
|
||||||
class="font-size-small text-muted"
|
|
||||||
>
|
|
||||||
Debited to <span class="text-normal font-bold">{{ draft.debitedAccount.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Middle row contains the description. -->
|
|
||||||
<div>
|
|
||||||
<p class="transaction-draft-card-description">{{ draft.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom row contains other links. -->
|
|
||||||
<div style="display: flex; justify-content: space-between">
|
|
||||||
<div>
|
|
||||||
<CategoryLabel
|
|
||||||
:category="draft.category"
|
|
||||||
v-if="draft.category"
|
|
||||||
style="margin-left: 0"
|
|
||||||
/>
|
|
||||||
<AppBadge v-if="draft.vendor">{{ draft.vendor.name }}</AppBadge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<!-- Only show the first 3 tags, and add a "+N" badge for any more. -->
|
|
||||||
<TagLabel
|
|
||||||
v-for="tag in draft.tags.slice(0, 3)"
|
|
||||||
:key="tag"
|
|
||||||
:tag="tag"
|
|
||||||
/>
|
|
||||||
<AppBadge
|
|
||||||
v-if="draft.tags.length > 3"
|
|
||||||
class="text-muted"
|
|
||||||
>+{{ draft.tags.length - 3 }}</AppBadge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<style>
|
|
||||||
.transaction-draft-card {
|
|
||||||
background-color: var(--bg);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 120px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transaction-draft-card:hover {
|
|
||||||
background-color: var(--bg-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transaction-draft-card-description {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ApiError } from '@/api/base'
|
|
||||||
import { formatMoney } from '@/api/data'
|
|
||||||
import { getSelectedProfile } from '@/api/profile'
|
|
||||||
import { TransactionApiClient, type TransactionDraftResponse } from '@/api/transaction'
|
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
|
||||||
import AppPage from '@/components/common/AppPage.vue'
|
|
||||||
import CategoryLabel from '@/components/CategoryLabel.vue'
|
|
||||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
|
||||||
import TagLabel from '@/components/TagLabel.vue'
|
|
||||||
import { showAlert, showConfirm } from '@/util/alert'
|
|
||||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
|
||||||
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
|
||||||
import LineItemCard from '@/components/LineItemCard.vue'
|
|
||||||
import AppBadge from '@/components/common/AppBadge.vue'
|
|
||||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
|
||||||
|
|
||||||
const draft: Ref<TransactionDraftResponse | undefined> = ref()
|
|
||||||
const pageTitle = computed(() => {
|
|
||||||
if (draft.value === undefined) return 'Transaction Draft'
|
|
||||||
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
|
|
||||||
return `Transaction Template ${draft.value.id}: "${draft.value.templateName}"`
|
|
||||||
}
|
|
||||||
return 'Transaction Draft ' + draft.value.id
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const draftId = parseInt(route.params.id as string)
|
|
||||||
try {
|
|
||||||
draft.value = await transactionApi.getDraft(draftId)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
await router.replace('/')
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
await showAlert('Failed to fetch transaction: ' + err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function deleteDraft() {
|
|
||||||
if (!draft.value) return
|
|
||||||
const conf = await showConfirm(
|
|
||||||
'Are you sure you want to delete this draft? This will permanently delete all data pertaining to this draft, and it cannot be recovered.',
|
|
||||||
)
|
|
||||||
if (!conf) return
|
|
||||||
try {
|
|
||||||
await transactionApi.deleteDraft(draft.value.id)
|
|
||||||
await router.replace(`/profiles/${getSelectedProfile(route)}`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onVendorClicked() {
|
|
||||||
if (draft.value && draft.value.vendor) {
|
|
||||||
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<AppPage
|
|
||||||
:title="pageTitle"
|
|
||||||
v-if="draft"
|
|
||||||
>
|
|
||||||
<!-- Top-row with some badges for amount, vendor, and category. -->
|
|
||||||
<div>
|
|
||||||
<AppBadge
|
|
||||||
size="lg"
|
|
||||||
class="font-mono"
|
|
||||||
v-if="draft.currency && draft.amount"
|
|
||||||
>
|
|
||||||
{{ draft.currency.code }} {{ formatMoney(draft.amount, draft.currency) }}
|
|
||||||
</AppBadge>
|
|
||||||
<AppBadge
|
|
||||||
size="md"
|
|
||||||
v-if="draft.vendor"
|
|
||||||
style="cursor: pointer"
|
|
||||||
@click="onVendorClicked()"
|
|
||||||
>
|
|
||||||
{{ draft.vendor.name }}
|
|
||||||
</AppBadge>
|
|
||||||
<CategoryLabel
|
|
||||||
v-if="draft.category"
|
|
||||||
:category="draft.category"
|
|
||||||
:clickable="true"
|
|
||||||
/>
|
|
||||||
<AppBadge
|
|
||||||
size="sm"
|
|
||||||
v-if="draft.internalTransfer"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="fa-rotate"></font-awesome-icon>
|
|
||||||
Internal Transfer
|
|
||||||
</AppBadge>
|
|
||||||
</div>
|
|
||||||
<!-- Second row that lists all tags. -->
|
|
||||||
<div
|
|
||||||
v-if="draft.tags.length > 0"
|
|
||||||
class="mt-1"
|
|
||||||
>
|
|
||||||
<TagLabel
|
|
||||||
v-for="t in draft.tags"
|
|
||||||
:key="t"
|
|
||||||
:tag="t"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>{{ draft.description }}</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="draft.creditedAccount"
|
|
||||||
class="my-1"
|
|
||||||
>
|
|
||||||
<strong class="text-negative">Credited</strong> from
|
|
||||||
<RouterLink
|
|
||||||
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.creditedAccount.id}`"
|
|
||||||
>
|
|
||||||
{{ draft.creditedAccount.name }} (#{{ draft.creditedAccount.numberSuffix }})
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="draft.debitedAccount"
|
|
||||||
class="my-1"
|
|
||||||
>
|
|
||||||
<strong class="text-positive">Debited</strong> to
|
|
||||||
<RouterLink
|
|
||||||
:to="`/profiles/${getSelectedProfile(route)}/accounts/${draft.debitedAccount.id}`"
|
|
||||||
>
|
|
||||||
{{ draft.debitedAccount.name }} (#{{ draft.debitedAccount.numberSuffix }})
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- All remaining properties are put in this table. -->
|
|
||||||
<PropertiesTable>
|
|
||||||
<tr>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
<td v-if="draft.timestamp !== null">{{ new Date(draft.timestamp).toLocaleString() }}</td>
|
|
||||||
<td v-if="draft.timestamp === null"><em>null</em></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Added to Finnow</th>
|
|
||||||
<td>{{ new Date(draft.addedAt).toLocaleString() }}</td>
|
|
||||||
</tr>
|
|
||||||
</PropertiesTable>
|
|
||||||
|
|
||||||
<div v-if="draft.lineItems.length > 0 && draft.currency">
|
|
||||||
<h3>Line Items</h3>
|
|
||||||
<LineItemCard
|
|
||||||
v-for="item of draft.lineItems"
|
|
||||||
:key="item.idx"
|
|
||||||
:line-item="item"
|
|
||||||
:currency="draft.currency"
|
|
||||||
:total-count="draft.lineItems.length"
|
|
||||||
:editable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="draft.attachments.length > 0">
|
|
||||||
<h3>Attachments</h3>
|
|
||||||
<AttachmentRow
|
|
||||||
v-for="a in draft.attachments"
|
|
||||||
:attachment="a"
|
|
||||||
:key="a.id"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ButtonBar>
|
|
||||||
<AppButton
|
|
||||||
icon="wrench"
|
|
||||||
@click="
|
|
||||||
router.push(`/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}/edit`)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</AppButton>
|
|
||||||
<AppButton
|
|
||||||
icon="trash"
|
|
||||||
@click="deleteDraft()"
|
|
||||||
>Delete</AppButton
|
|
||||||
>
|
|
||||||
</ButtonBar>
|
|
||||||
</AppPage>
|
|
||||||
</template>
|
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
<!--
|
||||||
|
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 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'
|
||||||
|
|
||||||
|
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<TransactionLineItemResponse[]> = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
|
@ -6,7 +6,6 @@ import HomeModule from '@/components/HomeModule.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { onMounted, ref, type Ref } from 'vue'
|
import { onMounted, ref, type Ref } from 'vue'
|
||||||
import { getSelectedProfile } from '@/api/profile'
|
import { getSelectedProfile } from '@/api/profile'
|
||||||
import TransactionDraftCard from '@/components/TransactionDraftCard.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const page: Ref<Page<TransactionDraftListItem>> = ref({
|
const page: Ref<Page<TransactionDraftListItem>> = ref({
|
||||||
|
|
@ -39,12 +38,12 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
@update="(pr) => fetchPage(pr)"
|
@update="(pr) => fetchPage(pr)"
|
||||||
class="align-right"
|
class="align-right"
|
||||||
/>
|
/>
|
||||||
<TransactionDraftCard
|
<div
|
||||||
v-for="draft in page.items"
|
v-for="draft in page.items"
|
||||||
:key="draft.id"
|
:key="draft.id"
|
||||||
:draft="draft"
|
>
|
||||||
/>
|
Draft ID: {{ draft.id }} Template name: {{ draft.templateName }}
|
||||||
<p v-if="page.totalElements === 0">There are no drafts.</p>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</HomeModule>
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
<!--
|
|
||||||
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 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 FormControl from '@/components/common/form/FormControl.vue'
|
|
||||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
|
||||||
import LineItemsEditor from '@/components/LineItemsEditor.vue'
|
|
||||||
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,
|
|
||||||
DraftEditorContext,
|
|
||||||
loadEditorContextFromRoute,
|
|
||||||
NewTransactionEditorContext,
|
|
||||||
TransactionEditorContext,
|
|
||||||
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 accountApi = new AccountApiClient(route)
|
|
||||||
|
|
||||||
// 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 === formData.value.currency?.code)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reactive form data:
|
|
||||||
const loading = ref(false)
|
|
||||||
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
|
|
||||||
const editorContext: Ref<TransactionEditorContextBase> = ref(new NewTransactionEditorContext())
|
|
||||||
const pageTitle = computed(() => {
|
|
||||||
if (editorContext.value instanceof NewTransactionEditorContext) {
|
|
||||||
return 'Add Transaction'
|
|
||||||
} else if (editorContext.value instanceof DraftEditorContext) {
|
|
||||||
return 'Edit Draft Transaction'
|
|
||||||
} else if (editorContext.value instanceof TransactionEditorContext) {
|
|
||||||
return 'Edit Transaction'
|
|
||||||
}
|
|
||||||
return 'Edit Transaction'
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(availableCurrencies, (newValue: Currency[]) => {
|
|
||||||
if (newValue.length === 1) {
|
|
||||||
formData.value.currency = 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))
|
|
||||||
|
|
||||||
try {
|
|
||||||
editorContext.value = await loadEditorContextFromRoute(route)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
formData.value = editorContext.value.initializeFormFields(route.query)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<AppPage :title="pageTitle">
|
|
||||||
<AppForm>
|
|
||||||
<!-- Initial draft-only form group: -->
|
|
||||||
<FormGroup v-if="editorContext instanceof DraftEditorContext">
|
|
||||||
<FormControl
|
|
||||||
label="Template Name"
|
|
||||||
hint="Add a name to this draft to turn it into a Template, which you can use when creating new transactions or scheduled transactions."
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="formData.templateName"
|
|
||||||
:disabled="loading"
|
|
||||||
style="max-width: 200px"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<!-- Basic properties -->
|
|
||||||
<FormControl label="Timestamp">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
v-model="formData.timestamp"
|
|
||||||
step="1"
|
|
||||||
:disabled="loading"
|
|
||||||
style="min-width: 250px"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl label="Amount">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
v-model="formData.amount"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
:disabled="loading"
|
|
||||||
style="max-width: 100px"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl label="Currency">
|
|
||||||
<select
|
|
||||||
v-model="formData.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="formData.description"
|
|
||||||
:disabled="loading"
|
|
||||||
></textarea>
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<!-- Vendor & Category -->
|
|
||||||
<FormControl label="Vendor">
|
|
||||||
<VendorSelect v-model="formData.vendor" />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl label="Category">
|
|
||||||
<CategorySelect v-model="formData.categoryId" />
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<!-- Accounts -->
|
|
||||||
<FormControl label="Credited Account">
|
|
||||||
<select
|
|
||||||
v-model="formData.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="formData.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="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="formData.tags" />
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<h5>Attachments</h5>
|
|
||||||
<FileSelector
|
|
||||||
:initial-files="formData.existingAttachments"
|
|
||||||
v-model:uploaded-files="formData.attachmentsToUpload"
|
|
||||||
v-model:removed-files="formData.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="formData.internalTransfer"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<!-- 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,457 +0,0 @@
|
||||||
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
|
|
||||||
* fields may only be used in certain contexts.
|
|
||||||
*/
|
|
||||||
export interface TransactionEditorFormFields {
|
|
||||||
timestamp: string | null
|
|
||||||
amount: number | null
|
|
||||||
templateName: string | null // Only for drafts, not transactions.
|
|
||||||
currency: Currency | null
|
|
||||||
description: string | null
|
|
||||||
internalTransfer: boolean | null
|
|
||||||
vendor: TransactionVendor | null
|
|
||||||
categoryId: number | null
|
|
||||||
creditedAccountId: number | null
|
|
||||||
debitedAccountId: number | null
|
|
||||||
lineItems: TransactionLineItemResponse[]
|
|
||||||
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 interface TransactionEditorContextBase {
|
|
||||||
isFormDataValid(formData: TransactionEditorFormFields): boolean
|
|
||||||
areChangesPresent(formData: TransactionEditorFormFields): boolean
|
|
||||||
initializeFormFields(
|
|
||||||
queryParams: Record<string, string | null | (string | null)[]>,
|
|
||||||
): TransactionEditorFormFields
|
|
||||||
getActions(formData: TransactionEditorFormFields): TransactionEditorAction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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.templateName !== null && d.templateName.length > 0) {
|
|
||||||
fields.templateName = d.templateName
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if (formData.amount !== null && formData.currency !== null) {
|
|
||||||
formData.amount = floatMoneyToInteger(formData.amount, formData.currency)
|
|
||||||
} else {
|
|
||||||
formData.amount = null
|
|
||||||
formData.currency = 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)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -44,7 +44,7 @@ const router = createRouter({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'accounts/:id/edit',
|
path: 'accounts/:id/edit',
|
||||||
component: () => import('@/pages/EditAccountPage.vue'),
|
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||||
meta: { title: 'Edit Account' },
|
meta: { title: 'Edit Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -54,7 +54,7 @@ const router = createRouter({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'add-account',
|
path: 'add-account',
|
||||||
component: () => import('@/pages/EditAccountPage.vue'),
|
component: () => import('@/pages/forms/EditAccountPage.vue'),
|
||||||
meta: { title: 'Add Account' },
|
meta: { title: 'Add Account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -63,32 +63,20 @@ const router = createRouter({
|
||||||
meta: { title: 'Transaction' },
|
meta: { title: 'Transaction' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'transaction-drafts/:id',
|
|
||||||
component: () => import('@/pages/TransactionDraftPage.vue'),
|
|
||||||
meta: { title: 'Draft' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'edit-transaction',
|
|
||||||
path: 'transactions/:id/edit',
|
path: 'transactions/:id/edit',
|
||||||
component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'),
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
meta: { title: 'Edit Transaction' },
|
meta: { title: 'Edit Transaction' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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',
|
path: 'transactions/search',
|
||||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||||
meta: { title: 'Search Transactions' },
|
meta: { title: 'Search Transactions' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'add-transaction',
|
||||||
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
|
meta: { title: 'Add Transaction' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'vendors',
|
path: 'vendors',
|
||||||
component: () => import('@/pages/VendorsPage.vue'),
|
component: () => import('@/pages/VendorsPage.vue'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue