Add idle-observer, format code

This commit is contained in:
andrewlalis 2025-10-23 17:30:19 -04:00
parent 534071cbe0
commit 7455a55766
8 changed files with 309 additions and 110 deletions

View File

@ -12,6 +12,7 @@
"@fortawesome/free-regular-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@idle-observer/vue3": "^0.2.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
@ -1248,6 +1249,21 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@idle-observer/core": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@idle-observer/core/-/core-0.2.0.tgz",
"integrity": "sha512-mr8dedtzGUGMo38oP4+gDGq/oGjY0f9aCddsCcOm5XZTDE5ALSL3zyvXT8+eOjMrwOVLkDXt4BoC7A9U6ImANw==",
"license": "MIT"
},
"node_modules/@idle-observer/vue3": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@idle-observer/vue3/-/vue3-0.2.0.tgz",
"integrity": "sha512-amI/uRRcHIdOI5x7wLGxGK3ewaBcljsIwoXQ16sDCnnHqAg8zwa9H1PMb3QzaRqH5b5Ck2MX0YOY6+pd2EEohQ==",
"license": "MIT",
"dependencies": {
"@idle-observer/core": "0.2.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -4827,9 +4843,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -20,6 +20,7 @@
"@fortawesome/free-regular-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@idle-observer/vue3": "^0.2.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",

View File

@ -30,7 +30,10 @@ function goToTransaction() {
}
</script>
<template>
<div class="transaction-card" @click="goToTransaction()">
<div
class="transaction-card"
@click="goToTransaction()"
>
<!-- Top row contains timestamp and amount. -->
<div class="transaction-card-top-row">
<div>
@ -40,16 +43,25 @@ function goToTransaction() {
</div>
</div>
<div>
<div class="font-mono align-right font-size-small" :class="{
'text-positive': moneyStyle === 'positive',
'text-negative': moneyStyle === 'negative',
}">
<div
class="font-mono align-right font-size-small"
:class="{
'text-positive': moneyStyle === 'positive',
'text-negative': moneyStyle === 'negative',
}"
>
{{ formatMoney(tx.amount, tx.currency) }}
</div>
<div v-if="tx.creditedAccount !== null" class="font-size-small text-muted">
<div
v-if="tx.creditedAccount !== null"
class="font-size-small text-muted"
>
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
</div>
<div v-if="tx.debitedAccount !== null" class="font-size-small text-muted">
<div
v-if="tx.debitedAccount !== null"
class="font-size-small text-muted"
>
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
</div>
</div>
@ -61,13 +73,21 @@ function goToTransaction() {
</div>
<!-- Bottom row contains other links. -->
<div style="display: flex; justify-content: space-between;">
<div style="display: flex; justify-content: space-between">
<div>
<CategoryLabel :category="tx.category" v-if="tx.category" style="margin-left: 0" />
<CategoryLabel
:category="tx.category"
v-if="tx.category"
style="margin-left: 0"
/>
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
</div>
<div>
<TagLabel v-for="tag in tx.tags" :key="tag" :tag="tag" />
<TagLabel
v-for="tag in tx.tags"
:key="tag"
:tag="tag"
/>
</div>
</div>
</div>

View File

@ -28,23 +28,42 @@ function incrementPage(step: number) {
<template>
<div>
<div v-if="page && page.totalElements > 0">
<AppButton size="sm" :disabled="!page || page.isFirst" @click="updatePage(1)">
<AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="updatePage(1)"
>
First Page
</AppButton>
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)">
<AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="incrementPage(-1)"
>
Previous Page
</AppButton>
<span style="min-width: 100px; text-align: center; display: inline-block;" class="font-size-xsmall">
<span
style="min-width: 100px; text-align: center; display: inline-block"
class="font-size-xsmall"
>
Page <span class="font-bold">{{ page?.pageRequest.page }}</span> of {{ page?.totalPages }}
</span>
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)">
<AppButton
size="sm"
:disabled="!page || page.isLast"
@click="incrementPage(1)"
>
Next Page
</AppButton>
<AppButton size="sm" :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)">
<AppButton
size="sm"
:disabled="!page || page.isLast"
@click="updatePage(page?.totalPages ?? 0)"
>
Last Page
</AppButton>
</div>

View File

@ -87,30 +87,58 @@ async function deleteTransaction() {
}
</script>
<template>
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
<AppPage
:title="'Transaction ' + transaction.id"
v-if="transaction"
>
<!-- Top-row with some badges for amount, vendor, and category. -->
<div>
<AppBadge size="lg" class="font-mono">
<AppBadge
size="lg"
class="font-mono"
>
{{ transaction.currency.code }} {{ formatMoney(transaction.amount, transaction.currency) }}
</AppBadge>
<AppBadge size="md" v-if="transaction.vendor">
<AppBadge
size="md"
v-if="transaction.vendor"
>
{{ transaction.vendor.name }}
</AppBadge>
<CategoryLabel v-if="transaction.category" :category="transaction.category" :clickable="true" />
<CategoryLabel
v-if="transaction.category"
:category="transaction.category"
:clickable="true"
/>
</div>
<!-- Second row that lists all tags. -->
<div v-if="transaction.tags.length > 0" class="mt-1">
<TagLabel v-for="t in transaction.tags" :key="t" :tag="t" />
<div
v-if="transaction.tags.length > 0"
class="mt-1"
>
<TagLabel
v-for="t in transaction.tags"
:key="t"
:tag="t"
/>
</div>
<p>{{ transaction.description }}</p>
<div v-if="transaction.creditedAccount" class="my-1">
<div
v-if="transaction.creditedAccount"
class="my-1"
>
<strong class="text-negative">Credited</strong> from
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.creditedAccount.id}`">
<RouterLink
:to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.creditedAccount.id}`"
>
{{ transaction.creditedAccount.name }} (#{{ transaction.creditedAccount.numberSuffix }})
</RouterLink>
<div v-if="creditedAccountBalanceDiff" class="font-size-xsmall">
<div
v-if="creditedAccountBalanceDiff"
class="font-size-xsmall"
>
Balance Before:
<span class="font-mono">
{{ formatMoney(creditedAccountBalanceDiff.before, transaction.currency) }}
@ -122,12 +150,20 @@ async function deleteTransaction() {
</div>
</div>
<div v-if="transaction.debitedAccount" class="my-1">
<div
v-if="transaction.debitedAccount"
class="my-1"
>
<strong class="text-positive">Debited</strong> to
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.debitedAccount.id}`">
<RouterLink
:to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.debitedAccount.id}`"
>
{{ transaction.debitedAccount.name }} (#{{ transaction.debitedAccount.numberSuffix }})
</RouterLink>
<div v-if="debitedAccountBalanceDiff" class="font-size-xsmall">
<div
v-if="debitedAccountBalanceDiff"
class="font-size-xsmall"
>
Balance Before:
<span class="font-mono">
{{ formatMoney(debitedAccountBalanceDiff.before, transaction.currency) }}
@ -153,21 +189,39 @@ async function deleteTransaction() {
<div v-if="transaction.lineItems.length > 0">
<h3>Line Items</h3>
<LineItemCard v-for="item of transaction.lineItems" :key="item.idx" :line-item="item"
:currency="transaction.currency" :total-count="transaction.lineItems.length" :editable="false" />
<LineItemCard
v-for="item of transaction.lineItems"
:key="item.idx"
:line-item="item"
:currency="transaction.currency"
:total-count="transaction.lineItems.length"
:editable="false"
/>
</div>
<div v-if="transaction.attachments.length > 0">
<h3>Attachments</h3>
<AttachmentRow v-for="a in transaction.attachments" :attachment="a" :key="a.id" disabled />
<AttachmentRow
v-for="a in transaction.attachments"
:attachment="a"
:key="a.id"
disabled
/>
</div>
<ButtonBar>
<AppButton icon="wrench" @click="
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
">
<AppButton
icon="wrench"
@click="
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
"
>
Edit
</AppButton>
<AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton>
<AppButton
icon="trash"
@click="deleteTransaction()"
>Delete</AppButton
>
</ButtonBar>
</AppPage>
</template>

View File

@ -1,29 +1,34 @@
<script setup lang="ts">
import { type Account, AccountApiClient } from '@/api/account';
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination';
import { getSelectedProfile } from '@/api/profile';
import { TransactionApiClient, type TransactionCategory, type TransactionVendor, type TransactionsListItem } from '@/api/transaction';
import AppBadge from '@/components/common/AppBadge.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppPage from '@/components/common/AppPage.vue';
import ButtonBar from '@/components/common/ButtonBar.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 PaginationControls from '@/components/common/PaginationControls.vue';
import TransactionCard from '@/components/TransactionCard.vue';
import { computed, onMounted, ref, watch, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VueSelect from 'vue3-select-component';
import { type Account, AccountApiClient } from '@/api/account'
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination'
import { getSelectedProfile } from '@/api/profile'
import {
TransactionApiClient,
type TransactionCategory,
type TransactionVendor,
type TransactionsListItem,
} from '@/api/transaction'
import AppBadge from '@/components/common/AppBadge.vue'
import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'
import ButtonBar from '@/components/common/ButtonBar.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 PaginationControls from '@/components/common/PaginationControls.vue'
import TransactionCard from '@/components/TransactionCard.vue'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VueSelect from 'vue3-select-component'
interface SortOption {
label: string
property: string
}
const SORT_PROPERTIES: SortOption[] = [
{ label: "Timestamp", property: "txn.timestamp" },
{ label: "Added at", property: "txn.added_at" },
{ label: "Amount", property: "txn.amount" }
{ label: 'Timestamp', property: 'txn.timestamp' },
{ label: 'Added at', property: 'txn.added_at' },
{ label: 'Amount', property: 'txn.amount' },
]
const FETCH_DEBOUNCE_DELAY = 300
@ -44,7 +49,7 @@ const searchQuery = ref('')
const tagFilters: Ref<string[]> = ref([])
const availableTags: Ref<string[]> = ref([])
const tagOptions = computed(() => {
return availableTags.value.map(tag => {
return availableTags.value.map((tag) => {
return { label: tag, value: tag }
})
})
@ -52,22 +57,26 @@ const tagOptions = computed(() => {
const vendorFilters = ref<number[]>([])
const availableVendors = ref<TransactionVendor[]>([])
const vendorOptions = computed(() => {
return availableVendors.value.map(vendor => {
return availableVendors.value.map((vendor) => {
return { label: vendor.name, value: vendor.id }
})
})
const categoryFilters = ref<number[]>([])
const availableCategories = ref<TransactionCategory[]>([])
const categoryOptions = computed(() => availableCategories.value.map(category => {
return { label: category.name, value: category.id }
}))
const categoryOptions = computed(() =>
availableCategories.value.map((category) => {
return { label: category.name, value: category.id }
}),
)
const accountFilters = ref<number[]>([])
const availableAccounts = ref<Account[]>([])
const accountOptions = computed(() => availableAccounts.value.map(acc => {
return { label: `${acc.name} - #${acc.numberSuffix}`, value: acc.id }
}))
const accountOptions = computed(() =>
availableAccounts.value.map((acc) => {
return { label: `${acc.name} - #${acc.numberSuffix}`, value: acc.id }
}),
)
const minAmountFilter = ref<number | undefined>(undefined)
const maxAmountFilter = ref<number | undefined>(undefined)
@ -75,11 +84,11 @@ const maxAmountFilter = ref<number | undefined>(undefined)
onMounted(async () => {
loadFiltersFromRoute()
const api = new TransactionApiClient(getSelectedProfile(route))
api.getAllTags().then(tags => availableTags.value = tags)
api.getVendors().then(vendors => availableVendors.value = vendors)
api.getCategoriesFlattened().then(categories => availableCategories.value = categories)
api.getAllTags().then((tags) => (availableTags.value = tags))
api.getVendors().then((vendors) => (availableVendors.value = vendors))
api.getCategoriesFlattened().then((categories) => (availableCategories.value = categories))
const accountApi = new AccountApiClient(route)
accountApi.getAccounts().then(accounts => availableAccounts.value = accounts)
accountApi.getAccounts().then((accounts) => (availableAccounts.value = accounts))
await fetchPage(1, 10)
watch(
@ -92,14 +101,14 @@ onMounted(async () => {
categoryFilters,
accountFilters,
minAmountFilter,
maxAmountFilter
maxAmountFilter,
],
() => {
window.clearTimeout(fetchTimeoutId.value)
fetchTimeoutId.value = window.setTimeout(() => {
fetchPage(1, 10)
}, FETCH_DEBOUNCE_DELAY)
}
},
)
})
@ -117,9 +126,9 @@ async function fetchPage(pg: number, size: number) {
sorts: [
{
attribute: selectedSort.value,
dir: selectedSortDir.value
}
]
dir: selectedSortDir.value,
},
],
}
const params = buildFiltersQuery()
const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString()
@ -139,25 +148,25 @@ async function fetchPage(pg: number, size: number) {
function buildFiltersQuery(): URLSearchParams {
const p = new URLSearchParams()
if (searchQuery.value.trim().length > 0) {
p.append("q", searchQuery.value.trim())
p.append('q', searchQuery.value.trim())
}
for (const tag of tagFilters.value) {
p.append("tag", tag)
p.append('tag', tag)
}
for (const vendorId of vendorFilters.value) {
p.append("vendor", vendorId + "")
p.append('vendor', vendorId + '')
}
for (const categoryId of categoryFilters.value) {
p.append("category", categoryId + "")
p.append('category', categoryId + '')
}
for (const accountId of accountFilters.value) {
p.append("account", accountId + "")
p.append('account', accountId + '')
}
if (minAmountFilter.value !== undefined && minAmountFilter.value > 0) {
p.append("min-amount", minAmountFilter.value * 100 + "")
p.append('min-amount', minAmountFilter.value * 100 + '')
}
if (maxAmountFilter.value !== undefined && maxAmountFilter.value > 0) {
p.append("max-amount", maxAmountFilter.value * 100 + "")
p.append('max-amount', maxAmountFilter.value * 100 + '')
}
return p
}
@ -177,16 +186,16 @@ function goToHome() {
}
function loadFiltersFromRoute() {
searchQuery.value = loadFirstParamValue("q") ?? ''
tagFilters.value = loadAllParamValues("tag")
vendorFilters.value = loadAllParamValues("vendor").map(s => parseInt(s))
categoryFilters.value = loadAllParamValues("category").map(s => parseInt(s))
accountFilters.value = loadAllParamValues("account").map(s => parseInt(s))
const minAmount = loadFirstParamValue("min-amount")
searchQuery.value = loadFirstParamValue('q') ?? ''
tagFilters.value = loadAllParamValues('tag')
vendorFilters.value = loadAllParamValues('vendor').map((s) => parseInt(s))
categoryFilters.value = loadAllParamValues('category').map((s) => parseInt(s))
accountFilters.value = loadAllParamValues('account').map((s) => parseInt(s))
const minAmount = loadFirstParamValue('min-amount')
if (minAmount !== undefined) {
minAmountFilter.value = Math.round(parseInt(minAmount) / 100)
}
const maxAmount = loadFirstParamValue("max-amount")
const maxAmount = loadFirstParamValue('max-amount')
if (maxAmount !== undefined) {
maxAmountFilter.value = Math.round(parseInt(maxAmount) / 100)
}
@ -205,7 +214,7 @@ function loadFirstParamValue(key: string): string | undefined {
function loadAllParamValues(key: string): string[] {
if (key in route.query) {
if (Array.isArray(route.query[key])) {
return route.query[key].filter(s => s !== null)
return route.query[key].filter((s) => s !== null)
} else if (route.query[key] !== null) {
return [route.query[key]]
}
@ -217,43 +226,84 @@ function loadAllParamValues(key: string): string[] {
<AppPage title="Transactions">
<AppForm>
<FormGroup>
<FormControl label="Search" hint="Free-form text search against description, tags, vendor, category, account.">
<input v-model="searchQuery" type="text" placeholder="Search for transactions..." />
<FormControl
label="Search"
hint="Free-form text search against description, tags, vendor, category, account."
>
<input
v-model="searchQuery"
type="text"
placeholder="Search for transactions..."
/>
</FormControl>
</FormGroup>
<FormGroup>
<div class="vueselect-control">
<h5>Tag</h5>
<VueSelect v-model="tagFilters" :options="tagOptions" placeholder="Select tags" is-multi />
<VueSelect
v-model="tagFilters"
:options="tagOptions"
placeholder="Select tags"
is-multi
/>
</div>
<div class="vueselect-control">
<h5>Vendor</h5>
<VueSelect v-model="vendorFilters" :options="vendorOptions" placeholder="Select vendors" is-multi />
<VueSelect
v-model="vendorFilters"
:options="vendorOptions"
placeholder="Select vendors"
is-multi
/>
</div>
<div class="vueselect-control">
<h5>Category</h5>
<VueSelect v-model="categoryFilters" :options="categoryOptions" placeholder="Select categories" is-multi />
<VueSelect
v-model="categoryFilters"
:options="categoryOptions"
placeholder="Select categories"
is-multi
/>
</div>
<div class="vueselect-control">
<h5>Account</h5>
<VueSelect v-model="accountFilters" :options="accountOptions" placeholder="Select accounts" is-multi />
<VueSelect
v-model="accountFilters"
:options="accountOptions"
placeholder="Select accounts"
is-multi
/>
</div>
</FormGroup>
<FormGroup>
<FormControl label="Max Amount">
<input v-model="maxAmountFilter" type="number" min="0" step="1" />
<input
v-model="maxAmountFilter"
type="number"
min="0"
step="1"
/>
</FormControl>
<FormControl label="Min Amount">
<input v-model="minAmountFilter" type="number" min="0" step="1" />
<input
v-model="minAmountFilter"
type="number"
min="0"
step="1"
/>
</FormControl>
</FormGroup>
<FormGroup>
<FormControl label="Sort By">
<select v-model="selectedSort">
<option v-for="sortOpt in SORT_PROPERTIES" :key="sortOpt.property" :value="sortOpt.property">{{
sortOpt.label }}
<option
v-for="sortOpt in SORT_PROPERTIES"
:key="sortOpt.property"
:value="sortOpt.property"
>
{{ sortOpt.label }}
</option>
</select>
</FormControl>
@ -265,18 +315,36 @@ function loadAllParamValues(key: string): string[] {
</FormControl>
</FormGroup>
<ButtonBar>
<AppButton size="sm" icon="home" @click="goToHome()">Back to Homepage</AppButton>
<AppButton size="sm" icon="trash" @click="clearFilters()">Clear Filters</AppButton>
<AppButton
size="sm"
icon="home"
@click="goToHome()"
>Back to Homepage</AppButton
>
<AppButton
size="sm"
icon="trash"
@click="clearFilters()"
>Clear Filters</AppButton
>
</ButtonBar>
</AppForm>
<PaginationControls :page="page" @update="(pr) => fetchPage(pr.page, pr.size)" class="align-right" />
<PaginationControls
:page="page"
@update="(pr) => fetchPage(pr.page, pr.size)"
class="align-right"
/>
<AppBadge size="sm">
{{ page.totalElements }} search
{{ page.totalElements == 1 ? 'result' : 'results' }}
in {{ lastFetchTime }} milliseconds
</AppBadge>
<TransactionCard v-for="txn in page.items" :key="txn.id" :tx="txn" />
<TransactionCard
v-for="txn in page.items"
:key="txn.id"
:tx="txn"
/>
</AppPage>
</template>
<style lang="css" scoped>
@ -285,7 +353,7 @@ function loadAllParamValues(key: string): string[] {
margin: 0.5rem;
}
.vueselect-control>h5 {
.vueselect-control > h5 {
font-size: 0.9rem;
font-weight: 700;
margin: 0;

View File

@ -61,16 +61,25 @@ async function checkAuth() {
<div>
<header class="app-header-bar">
<div>
<h1 class="app-header-text" @click="onHeaderClicked()">
<h1
class="app-header-text"
@click="onHeaderClicked()"
>
Finnow
</h1>
</div>
<div>
<span class="app-user-widget" @click="router.push('/me')">
<span
class="app-user-widget"
@click="router.push('/me')"
>
<font-awesome-icon icon="fa-user"></font-awesome-icon>
</span>
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
<span
class="app-logout-button"
@click="authStore.onUserLoggedOut()"
>
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
</span>
</div>

View File

@ -40,13 +40,25 @@ function goToSearch() {
<template>
<HomeModule title="Transactions">
<template v-slot:default>
<PaginationControls :page="transactions" @update="(pr) => fetchPage(pr)" class="align-right" />
<TransactionCard v-for="tx in transactions.items" :key="tx.id" :tx="tx" />
<PaginationControls
:page="transactions"
@update="(pr) => fetchPage(pr)"
class="align-right"
/>
<TransactionCard
v-for="tx in transactions.items"
:key="tx.id"
:tx="tx"
/>
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
</template>
<template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">
Add Transaction</AppButton>
<AppButton
icon="plus"
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
>
Add Transaction</AppButton
>
<AppButton @click="goToSearch()">Search</AppButton>
</template>
</HomeModule>