Formatted frontend code.
Build and Deploy Web App / build-and-deploy (push) Successful in 21s Details

This commit is contained in:
Andrew Lalis 2026-03-14 22:33:05 -04:00
parent 9cb2d562d8
commit 71a783669f
6 changed files with 223 additions and 64 deletions

View File

@ -37,7 +37,7 @@ watch(balancesIncludeSubcategories, async () => {
if (!category.value) return if (!category.value) return
balances.value = await new TransactionApiClient(getSelectedProfile(route)).getCategoryBalances( balances.value = await new TransactionApiClient(getSelectedProfile(route)).getCategoryBalances(
category.value.id, category.value.id,
balancesIncludeSubcategories.value balancesIncludeSubcategories.value,
) )
}) })
@ -60,7 +60,10 @@ async function loadCategory(id: number) {
parentCategory.value = await api.getCategory(category.value.parentId) parentCategory.value = await api.getCategory(category.value.parentId)
} }
childCategories.value = await api.getChildCategories(category.value.id) childCategories.value = await api.getChildCategories(category.value.id)
balances.value = await api.getCategoryBalances(category.value.id, balancesIncludeSubcategories.value) balances.value = await api.getCategoryBalances(
category.value.id,
balancesIncludeSubcategories.value,
)
await fetchPage(1) await fetchPage(1)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -87,16 +90,32 @@ async function fetchPage(pg: number) {
} }
</script> </script>
<template> <template>
<AppPage v-if="category" :title="'Category - ' + category.name"> <AppPage
v-if="category"
:title="'Category - ' + category.name"
>
<!-- Initial subtext with color & parent (if available). --> <!-- Initial subtext with color & parent (if available). -->
<div> <div>
<span class="category-color-indicator" :style="{ 'background-color': '#' + category.color }"></span> <span
<span v-if="parentCategory" class="text-muted" style="vertical-align: middle"> class="category-color-indicator"
:style="{ 'background-color': '#' + category.color }"
></span>
<span
v-if="parentCategory"
class="text-muted"
style="vertical-align: middle"
>
A subcategory of A subcategory of
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${parentCategory.id}`">{{ <RouterLink
parentCategory.name }}</RouterLink>. :to="`/profiles/${getSelectedProfile(route)}/categories/${parentCategory.id}`"
>{{ parentCategory.name }}</RouterLink
>.
</span> </span>
<span v-if="!parentCategory" class="text-muted" style="vertical-align: middle"> <span
v-if="!parentCategory"
class="text-muted"
style="vertical-align: middle"
>
This is a top-level category. This is a top-level category.
</span> </span>
</div> </div>
@ -108,8 +127,12 @@ async function fetchPage(pg: number) {
<div v-if="childCategories.length > 0"> <div v-if="childCategories.length > 0">
<h3>Subcategories</h3> <h3>Subcategories</h3>
<ul> <ul>
<li v-for="child in childCategories" :key="child.id"> <li
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${child.id}`">{{ child.name }} v-for="child in childCategories"
:key="child.id"
>
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${child.id}`"
>{{ child.name }}
</RouterLink> </RouterLink>
</li> </li>
</ul> </ul>
@ -118,22 +141,36 @@ async function fetchPage(pg: number) {
<!-- Display total balances. --> <!-- Display total balances. -->
<div v-if="balances.length > 0"> <div v-if="balances.length > 0">
<h3>Balances</h3> <h3>Balances</h3>
<div v-for="balance in balances" :key="balance.currency.code"> <div
v-for="balance in balances"
:key="balance.currency.code"
>
USD: USD:
<AppBadge>Debits: <AppBadge
>Debits:
<span class="text-positive">{{ formatMoney(balance.debits, balance.currency) }}</span> <span class="text-positive">{{ formatMoney(balance.debits, balance.currency) }}</span>
</AppBadge> </AppBadge>
<AppBadge>Credits: <AppBadge
>Credits:
<span class="text-negative">{{ formatMoney(balance.credits, balance.currency) }}</span> <span class="text-negative">{{ formatMoney(balance.credits, balance.currency) }}</span>
</AppBadge> </AppBadge>
<AppBadge>Balance: <AppBadge
<span :class="{ 'text-positive': balance.balance > 0, 'text-negative': balance.balance < 0 }">{{ >Balance:
formatMoney(balance.balance, balance.currency) }}</span> <span
:class="{ 'text-positive': balance.balance > 0, 'text-negative': balance.balance < 0 }"
>{{ formatMoney(balance.balance, balance.currency) }}</span
>
</AppBadge> </AppBadge>
</div> </div>
<FormGroup> <FormGroup>
<FormControl label="Include Subcategories" v-if="childCategories.length > 0"> <FormControl
<input type="checkbox" v-model="balancesIncludeSubcategories" /> label="Include Subcategories"
v-if="childCategories.length > 0"
>
<input
type="checkbox"
v-model="balancesIncludeSubcategories"
/>
</FormControl> </FormControl>
<!-- <!--
<FormControl label="After"> <FormControl label="After">
@ -151,9 +188,20 @@ async function fetchPage(pg: number) {
<p class="text-muted font-size-small mt-0"> <p class="text-muted font-size-small mt-0">
Below is a list of all transactions recorded with this category, or any subcategory. Below is a list of all transactions recorded with this category, or any subcategory.
</p> </p>
<PaginationControls :page="relatedTransactionsPage" @update="(pr) => fetchPage(pr.page)" class="align-right" /> <PaginationControls
<TransactionCard v-for="txn in relatedTransactionsPage.items" :key="txn.id" :tx="txn" /> :page="relatedTransactionsPage"
<p v-if="relatedTransactionsPage.totalElements === 0" class="text-muted font-italic"> @update="(pr) => fetchPage(pr.page)"
class="align-right"
/>
<TransactionCard
v-for="txn in relatedTransactionsPage.items"
:key="txn.id"
:tx="txn"
/>
<p
v-if="relatedTransactionsPage.totalElements === 0"
class="text-muted font-italic"
>
There are no transactions linked to this category. There are no transactions linked to this category.
</p> </p>
</div> </div>

View File

@ -233,42 +233,83 @@ 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
v-for="sortOpt in SORT_PROPERTIES"
:key="sortOpt.property"
:value="sortOpt.property"
>
{{ sortOpt.label }} {{ sortOpt.label }}
</option> </option>
</select> </select>
@ -281,19 +322,42 @@ 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"
<AppButton size="sm" icon="file-export" @click="exportToFile()">Export to CSV</AppButton> icon="home"
@click="goToHome()"
>Back to Homepage</AppButton
>
<AppButton
size="sm"
icon="trash"
@click="clearFilters()"
>Clear Filters</AppButton
>
<AppButton
size="sm"
icon="file-export"
@click="exportToFile()"
>Export to CSV</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>
@ -302,7 +366,7 @@ function loadAllParamValues(key: string): string[] {
margin: 0.5rem; margin: 0.5rem;
} }
.vueselect-control>h5 { .vueselect-control > h5 {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;

View File

@ -56,27 +56,47 @@ function exportAccounts() {
<template> <template>
<HomeModule title="Accounts"> <HomeModule title="Accounts">
<template v-slot:default> <template v-slot:default>
<AccountCard v-for="a in accounts" :account="a" :key="a.id" /> <AccountCard
v-for="a in accounts"
:account="a"
:key="a.id"
/>
<p v-if="accounts.length === 0"> <p v-if="accounts.length === 0">
You haven't added any accounts. Add one to start tracking your finances. You haven't added any accounts. Add one to start tracking your finances.
</p> </p>
<div> <div>
<AppBadge v-for="bal in totalBalances" :key="bal.currency.code"> <AppBadge
v-for="bal in totalBalances"
:key="bal.currency.code"
>
{{ bal.currency.code }} Total: {{ bal.currency.code }} Total:
<span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span> <span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span>
</AppBadge> </AppBadge>
<AppBadge v-for="debt in totalOwed" :key="debt.currency.code"> <AppBadge
v-for="debt in totalOwed"
:key="debt.currency.code"
>
{{ debt.currency.code }} Debt: {{ debt.currency.code }} Debt:
<span class="font-mono" :class="{ 'text-negative': debt.balance > 0 }">{{ formatMoney(debt.balance, <span
debt.currency) }}</span> class="font-mono"
:class="{ 'text-negative': debt.balance > 0 }"
>{{ formatMoney(debt.balance, debt.currency) }}</span
>
</AppBadge> </AppBadge>
</div> </div>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account <AppButton
icon="plus"
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)"
>Add Account
</AppButton> </AppButton>
<AppButton icon="download" size="sm" @click="exportAccounts()"> <AppButton
icon="download"
size="sm"
@click="exportAccounts()"
>
Export Export
</AppButton> </AppButton>
</template> </template>

View File

@ -4,9 +4,7 @@ import HomeModule from '@/components/HomeModule.vue'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import 'chartjs-adapter-date-fns' import 'chartjs-adapter-date-fns'
import type { Currency } from '@/api/data' import type { Currency } from '@/api/data'
import { import { AnalyticsApiClient } from '@/api/analytics'
AnalyticsApiClient,
} from '@/api/analytics'
import type { TimeFrame } from './analytics/util' import type { TimeFrame } from './analytics/util'
import FormGroup from '@/components/common/form/FormGroup.vue' import FormGroup from '@/components/common/form/FormGroup.vue'
import FormControl from '@/components/common/form/FormControl.vue' import FormControl from '@/components/common/form/FormControl.vue'
@ -60,30 +58,53 @@ onMounted(async () => {
<FormGroup> <FormGroup>
<FormControl label="Chart"> <FormControl label="Chart">
<select v-model="selectedChart"> <select v-model="selectedChart">
<option v-for="ct in AnalyticsChartTypes" :key="ct.id" :value="ct"> <option
v-for="ct in AnalyticsChartTypes"
:key="ct.id"
:value="ct"
>
{{ ct.name }} {{ ct.name }}
</option> </option>
</select> </select>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<BalanceTimeSeriesChart v-if="currency && selectedChart.id === 'account-balances'" title="Account Balances" <BalanceTimeSeriesChart
:currency="currency" :time-frame="timeFrame" :api="analyticsApi" /> v-if="currency && selectedChart.id === 'account-balances'"
title="Account Balances"
:currency="currency"
:time-frame="timeFrame"
:api="analyticsApi"
/>
<CategorySpendPieChart v-if="currency && selectedChart.id === 'category-spend'" :currency="currency" <CategorySpendPieChart
:api="analyticsApi" :time-frame="timeFrame" /> v-if="currency && selectedChart.id === 'category-spend'"
:currency="currency"
:api="analyticsApi"
:time-frame="timeFrame"
/>
<FormGroup> <FormGroup>
<FormControl label="Currency"> <FormControl label="Currency">
<select v-model="currency" :disabled="availableCurrencies.length < 2"> <select
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency"> v-model="currency"
:disabled="availableCurrencies.length < 2"
>
<option
v-for="currency in availableCurrencies"
:key="currency.code"
:value="currency"
>
{{ currency.code }} {{ currency.code }}
</option> </option>
</select> </select>
</FormControl> </FormControl>
<FormControl label="Time Frame"> <FormControl label="Time Frame">
<select v-model="timeFrame"> <select v-model="timeFrame">
<option :value="{}" selected> <option
:value="{}"
selected
>
All Time All Time
</option> </option>
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option> <option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>

View File

@ -61,14 +61,14 @@ watch(
() => { () => {
buildChartData() buildChartData()
}, },
{ immediate: true, deep: true } { immediate: true, deep: true },
) )
async function buildChartData() { async function buildChartData() {
const balanceAnalytics = await props.api.getBalanceTimeSeries( const balanceAnalytics = await props.api.getBalanceTimeSeries(
props.currency.code, props.currency.code,
props.timeFrame.start ? props.timeFrame.start.getTime() : null, props.timeFrame.start ? props.timeFrame.start.getTime() : null,
props.timeFrame.end ? props.timeFrame.end.getTime() : null props.timeFrame.end ? props.timeFrame.end.getTime() : null,
) )
const datasets: ChartDataset<'line'>[] = [] const datasets: ChartDataset<'line'>[] = []
@ -101,6 +101,10 @@ async function buildChartData() {
</script> </script>
<template> <template>
<div> <div>
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" /> <Line
v-if="chartData && chartOptions"
:data="chartData"
:options="chartOptions"
/>
</div> </div>
</template> </template>

View File

@ -33,7 +33,7 @@ watch(
() => { () => {
buildChartData() buildChartData()
}, },
{ immediate: true, deep: true } { immediate: true, deep: true },
) )
async function buildChartData() { async function buildChartData() {
@ -41,7 +41,7 @@ async function buildChartData() {
props.currency.code, props.currency.code,
props.timeFrame.start ? props.timeFrame.start.getTime() : null, props.timeFrame.start ? props.timeFrame.start.getTime() : null,
props.timeFrame.end ? props.timeFrame.end.getTime() : null, props.timeFrame.end ? props.timeFrame.end.getTime() : null,
null null,
) )
if (categorySpendData.length === 0) { if (categorySpendData.length === 0) {
chartData.value = undefined chartData.value = undefined
@ -69,11 +69,13 @@ async function buildChartData() {
} }
</script> </script>
<template> <template>
<div> <div>
<Pie id="pie" v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" /> <Pie
<p v-if="!chartData"> id="pie"
No category spending data is available for this selection of filters. v-if="chartData && chartOptions"
</p> :data="chartData"
:options="chartOptions"
/>
<p v-if="!chartData">No category spending data is available for this selection of filters.</p>
</div> </div>
</template> </template>