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.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"),
|
||||||
|
|
@ -881,7 +882,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
row.peek!string(20)
|
row.peek!string(20)
|
||||||
).toOptional;
|
).toOptional;
|
||||||
}
|
}
|
||||||
string aggregateTags = row.peek!(string, PeekMode.slice)(21);
|
string aggregateTags = row.peek!string(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(",");
|
||||||
|
|
|
||||||
|
|
@ -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 { 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({
|
||||||
|
|
@ -38,12 +39,12 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
@update="(pr) => fetchPage(pr)"
|
@update="(pr) => fetchPage(pr)"
|
||||||
class="align-right"
|
class="align-right"
|
||||||
/>
|
/>
|
||||||
<div
|
<TransactionDraftCard
|
||||||
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 }}
|
/>
|
||||||
</div>
|
<p v-if="page.totalElements === 0">There are no drafts.</p>
|
||||||
</template>
|
</template>
|
||||||
</HomeModule>
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import VendorSelect from '@/components/VendorSelect.vue'
|
||||||
import TagsSelect from '@/components/TagsSelect.vue'
|
import TagsSelect from '@/components/TagsSelect.vue'
|
||||||
import {
|
import {
|
||||||
defaultEmptyFormFields,
|
defaultEmptyFormFields,
|
||||||
|
DraftEditorContext,
|
||||||
loadEditorContextFromRoute,
|
loadEditorContextFromRoute,
|
||||||
NewTransactionEditorContext,
|
NewTransactionEditorContext,
|
||||||
|
TransactionEditorContext,
|
||||||
type TransactionEditorContextBase,
|
type TransactionEditorContextBase,
|
||||||
type TransactionEditorFormFields,
|
type TransactionEditorFormFields,
|
||||||
} from './util'
|
} from './util'
|
||||||
|
|
@ -54,6 +56,16 @@ const availableAccounts = computed(() => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
|
const formData: Ref<TransactionEditorFormFields> = ref(defaultEmptyFormFields())
|
||||||
const editorContext: Ref<TransactionEditorContextBase> = ref(new NewTransactionEditorContext())
|
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[]) => {
|
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||||
if (newValue.length === 1) {
|
if (newValue.length === 1) {
|
||||||
|
|
@ -77,8 +89,23 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppPage :title="false ? 'Edit Transaction' : 'Add Transaction'">
|
<AppPage :title="pageTitle">
|
||||||
<AppForm>
|
<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>
|
<FormGroup>
|
||||||
<!-- Basic properties -->
|
<!-- Basic properties -->
|
||||||
<FormControl label="Timestamp">
|
<FormControl label="Timestamp">
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,9 @@ export class DraftEditorContext implements TransactionEditorContextBase {
|
||||||
initializeFormFields(): TransactionEditorFormFields {
|
initializeFormFields(): TransactionEditorFormFields {
|
||||||
const d = this.existingDraft
|
const d = this.existingDraft
|
||||||
const fields = defaultEmptyFormFields()
|
const fields = defaultEmptyFormFields()
|
||||||
|
if (d.templateName !== null && d.templateName.length > 0) {
|
||||||
|
fields.templateName = d.templateName
|
||||||
|
}
|
||||||
if (d.timestamp !== null) {
|
if (d.timestamp !== null) {
|
||||||
fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp)
|
fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp)
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +394,12 @@ function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraft
|
||||||
if (typeof formData.amount === 'string') {
|
if (typeof formData.amount === 'string') {
|
||||||
formData.amount = null
|
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
|
let isoTimestamp = null
|
||||||
if (formData.timestamp !== null && formData.timestamp.length > 0) {
|
if (formData.timestamp !== null && formData.timestamp.length > 0) {
|
||||||
isoTimestamp = new Date(formData.timestamp).toISOString()
|
isoTimestamp = new Date(formData.timestamp).toISOString()
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/TransactionPage.vue'),
|
component: () => import('@/pages/TransactionPage.vue'),
|
||||||
meta: { title: 'Transaction' },
|
meta: { title: 'Transaction' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'transaction-drafts/:id',
|
||||||
|
component: () => import('@/pages/TransactionDraftPage.vue'),
|
||||||
|
meta: { title: 'Draft' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'edit-transaction',
|
name: 'edit-transaction',
|
||||||
path: 'transactions/:id/edit',
|
path: 'transactions/:id/edit',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue