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

View File

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

View File

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

View File

@ -28,23 +28,42 @@ function incrementPage(step: number) {
<template> <template>
<div> <div>
<div v-if="page && page.totalElements > 0"> <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 First Page
</AppButton> </AppButton>
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)"> <AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="incrementPage(-1)"
>
Previous Page Previous Page
</AppButton> </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 }} Page <span class="font-bold">{{ page?.pageRequest.page }}</span> of {{ page?.totalPages }}
</span> </span>
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)"> <AppButton
size="sm"
:disabled="!page || page.isLast"
@click="incrementPage(1)"
>
Next Page Next Page
</AppButton> </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 Last Page
</AppButton> </AppButton>
</div> </div>

View File

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

View File

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

View File

@ -61,16 +61,25 @@ async function checkAuth() {
<div> <div>
<header class="app-header-bar"> <header class="app-header-bar">
<div> <div>
<h1 class="app-header-text" @click="onHeaderClicked()"> <h1
class="app-header-text"
@click="onHeaderClicked()"
>
Finnow Finnow
</h1> </h1>
</div> </div>
<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> <font-awesome-icon icon="fa-user"></font-awesome-icon>
</span> </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> <font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
</span> </span>
</div> </div>

View File

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