Added draft card and page components, added specialization for template drafts.
This commit is contained in:
parent
ed9b53ee79
commit
23cfe0b1a9
|
|
@ -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(",");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue