Added draft card and page components, added specialization for template drafts.
Build Web App / build-and-deploy (push) Successful in 18s Details
Build and Test API / build-and-deploy (push) Successful in 1m48s Details

This commit is contained in:
Andrew Lalis 2026-06-28 21:33:08 -04:00
parent ed9b53ee79
commit 23cfe0b1a9
7 changed files with 352 additions and 6 deletions

View File

@ -682,6 +682,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
response.category = draft.category;
response.creditedAccount = draft.creditedAccount;
response.debitedAccount = draft.debitedAccount;
response.tags = li.value.tags;
response.lineItems = util.sqlite.findAll(
db,
import("sql/query/get_line_items_draft.sql"),
@ -881,7 +882,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
row.peek!string(20)
).toOptional;
}
string aggregateTags = row.peek!(string, PeekMode.slice)(21);
string aggregateTags = row.peek!string(21);
if (aggregateTags !is null) {
import std.string : split;
item.tags = aggregateTags.split(",");

View File

@ -0,0 +1,115 @@
<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>

View File

@ -0,0 +1,188 @@
<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>

View File

@ -6,6 +6,7 @@ import HomeModule from '@/components/HomeModule.vue'
import { useRoute } from 'vue-router'
import { onMounted, ref, type Ref } from 'vue'
import { getSelectedProfile } from '@/api/profile'
import TransactionDraftCard from '@/components/TransactionDraftCard.vue'
const route = useRoute()
const page: Ref<Page<TransactionDraftListItem>> = ref({
@ -38,12 +39,12 @@ async function fetchPage(pageRequest: PageRequest) {
@update="(pr) => fetchPage(pr)"
class="align-right"
/>
<div
<TransactionDraftCard
v-for="draft in page.items"
:key="draft.id"
>
Draft ID: {{ draft.id }} Template name: {{ draft.templateName }}
</div>
:draft="draft"
/>
<p v-if="page.totalElements === 0">There are no drafts.</p>
</template>
</HomeModule>
</template>

View File

@ -25,8 +25,10 @@ import VendorSelect from '@/components/VendorSelect.vue'
import TagsSelect from '@/components/TagsSelect.vue'
import {
defaultEmptyFormFields,
DraftEditorContext,
loadEditorContextFromRoute,
NewTransactionEditorContext,
TransactionEditorContext,
type TransactionEditorContextBase,
type TransactionEditorFormFields,
} from './util'
@ -54,6 +56,16 @@ const availableAccounts = computed(() => {
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) {
@ -77,8 +89,23 @@ onMounted(async () => {
})
</script>
<template>
<AppPage :title="false ? 'Edit Transaction' : 'Add Transaction'">
<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">

View File

@ -290,6 +290,9 @@ export class DraftEditorContext implements TransactionEditorContextBase {
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)
}
@ -391,6 +394,12 @@ function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraft
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()

View File

@ -62,6 +62,11 @@ const router = createRouter({
component: () => import('@/pages/TransactionPage.vue'),
meta: { title: 'Transaction' },
},
{
path: 'transaction-drafts/:id',
component: () => import('@/pages/TransactionDraftPage.vue'),
meta: { title: 'Draft' },
},
{
name: 'edit-transaction',
path: 'transactions/:id/edit',