Added category page.
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details
Build and Deploy API / build-and-deploy (push) Failing after 28s Details

This commit is contained in:
andrewlalis 2025-12-02 16:18:47 -05:00
parent 3368cfa96c
commit d507aef570
7 changed files with 161 additions and 46 deletions

View File

@ -77,6 +77,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
// Transaction category endpoints: // Transaction category endpoints:
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories); a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory); a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong/children", &handleGetChildCategories);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory); a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory); a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory); a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory);

View File

@ -132,6 +132,11 @@ void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse res
writeJsonBody(response, category); writeJsonBody(response, category);
} }
void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto children = getChildCategories(getProfileDataSource(request), getCategoryId(request));
writeJsonBody(response, children);
}
struct CategoryPayload { struct CategoryPayload {
string name; string name;
string description; string description;

View File

@ -341,6 +341,13 @@ TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId)
return TransactionCategoryResponse.of(category); return TransactionCategoryResponse.of(category);
} }
TransactionCategoryResponse[] getChildCategories(ProfileDataSource ds, ulong categoryId) {
import std.algorithm : map;
import std.array : array;
auto categories = ds.getTransactionCategoryRepository().findAllByParentId(Optional!ulong.of(categoryId));
return categories.map!(TransactionCategoryResponse.of).array;
}
TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) { TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
if (payload.name is null || payload.name.length == 0) { if (payload.name is null || payload.name.length == 0) {

View File

@ -176,6 +176,10 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/categories/' + id) return super.getJson(this.path + '/categories/' + id)
} }
getChildCategories(parentId: number): Promise<TransactionCategory[]> {
return super.getJson(this.path + '/categories/' + parentId + '/children')
}
createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> { createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> {
return super.postJson(this.path + '/categories', data) return super.postJson(this.path + '/categories', data)
} }

View File

@ -2,6 +2,10 @@
import type { TransactionCategoryTree } from '@/api/transaction' import type { TransactionCategoryTree } from '@/api/transaction'
import AppButton from './common/AppButton.vue' import AppButton from './common/AppButton.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router';
import { getSelectedProfile } from '@/api/profile';
const route = useRoute()
const props = defineProps<{ const props = defineProps<{
category: TransactionCategoryTree category: TransactionCategoryTree
@ -15,59 +19,30 @@ const expanded = ref(false)
const canExpand = computed(() => props.category.children.length > 0) const canExpand = computed(() => props.category.children.length > 0)
</script> </script>
<template> <template>
<div <div class="category-display-item" :class="{
class="category-display-item"
:class="{
'category-display-item-bg-1': category.depth % 2 === 0, 'category-display-item-bg-1': category.depth % 2 === 0,
'category-display-item-bg-2': category.depth % 2 === 1, 'category-display-item-bg-2': category.depth % 2 === 1,
}" }">
>
<div class="category-display-item-content"> <div class="category-display-item-content">
<div> <div>
<h4 class="category-display-item-title">{{ category.name }}</h4> <h4 class="category-display-item-title">
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${category.id}`">{{ category.name }}
</RouterLink>
</h4>
<p class="category-display-item-description">{{ category.description }}</p> <p class="category-display-item-description">{{ category.description }}</p>
</div> </div>
<div <div class="category-display-item-color-indicator" :style="{ 'background-color': '#' + category.color }"></div>
class="category-display-item-color-indicator"
:style="{ 'background-color': '#' + category.color }"
></div>
</div> </div>
<div <div v-if="editable" style="text-align: right">
v-if="editable" <AppButton icon="chevron-down" v-if="canExpand && !expanded" @click="expanded = true" />
style="text-align: right" <AppButton icon="chevron-up" v-if="canExpand && expanded" @click="expanded = false" />
> <AppButton icon="wrench" @click="$emit('edited', category.id)" />
<AppButton <AppButton icon="trash" @click="$emit('deleted', category.id)" />
icon="chevron-down"
v-if="canExpand && !expanded"
@click="expanded = true"
/>
<AppButton
icon="chevron-up"
v-if="canExpand && expanded"
@click="expanded = false"
/>
<AppButton
icon="wrench"
@click="$emit('edited', category.id)"
/>
<AppButton
icon="trash"
@click="$emit('deleted', category.id)"
/>
</div> </div>
<!-- Nested display item for each child: --> <!-- Nested display item for each child: -->
<div <div style="margin-left: 1rem" v-if="canExpand && expanded">
style="margin-left: 1rem" <CategoryDisplayItem v-for="child in category.children" :key="child.id" :category="child" :editable="editable"
v-if="canExpand && expanded" @edited="(c) => $emit('edited', c)" @deleted="(c) => $emit('deleted', c)" />
>
<CategoryDisplayItem
v-for="child in category.children"
:key="child.id"
:category="child"
:editable="editable"
@edited="(c) => $emit('edited', c)"
@deleted="(c) => $emit('deleted', c)"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import { defaultPage, type Page, type PageRequest } from '@/api/pagination';
import { getSelectedProfile } from '@/api/profile';
import { TransactionApiClient, type TransactionCategory, type TransactionsListItem } from '@/api/transaction';
import AppPage from '@/components/common/AppPage.vue';
import PaginationControls from '@/components/common/PaginationControls.vue';
import TransactionCard from '@/components/TransactionCard.vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute()
const router = useRouter()
const category = ref<TransactionCategory | undefined>()
const parentCategory = ref<TransactionCategory | undefined>()
const childCategories = ref<TransactionCategory[]>([])
const relatedTransactionsPage = ref<Page<TransactionsListItem>>(defaultPage())
watch(
() => route.params.id,
(newId) => loadCategory(parseInt(newId as string))
)
onMounted(async () => {
const categoryId = parseInt(route.params.id as string)
await loadCategory(categoryId)
})
async function loadCategory(id: number) {
category.value = undefined
parentCategory.value = undefined
childCategories.value = []
relatedTransactionsPage.value = defaultPage()
try {
const api = new TransactionApiClient(getSelectedProfile(route))
category.value = await api.getCategory(id)
if (category.value.parentId !== null) {
parentCategory.value = await api.getCategory(category.value.parentId)
}
childCategories.value = await api.getChildCategories(category.value.id)
console.log(childCategories.value)
await fetchPage(1)
} catch (err) {
console.error(err)
await router.replace('/')
}
}
async function fetchPage(pg: number) {
if (!category.value) return
const pageRequest: PageRequest = {
page: pg,
size: 10,
sorts: [
{
attribute: 'txn.timestamp',
dir: 'DESC'
}
]
}
const params = new URLSearchParams()
params.append('category', category.value?.id + '')
const api = new TransactionApiClient(getSelectedProfile(route))
relatedTransactionsPage.value = await api.searchTransactions(params, pageRequest)
}
</script>
<template>
<AppPage v-if="category" :title="'Category - ' + category.name">
<!-- Initial subtext with color & parent (if available). -->
<div>
<span class="category-color-indicator" :style="{ 'background-color': '#' + category.color }"></span>
<span v-if="parentCategory" class="text-muted" style="vertical-align: middle;">
A subcategory of <RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${parentCategory.id}`">{{
parentCategory.name }}</RouterLink>.
</span>
<span v-if="!parentCategory" class="text-muted" style="vertical-align: middle;">
This is a top-level category.
</span>
</div>
<p>
{{ category.description }}
</p>
<div v-if="childCategories.length > 0">
<h3>Child Categories</h3>
<ul>
<li v-for="child in childCategories" :key="child.id">
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/categories/${child.id}`">{{ child.name }}
</RouterLink>
</li>
</ul>
</div>
<div>
<h3 class="mb-0">Transactions</h3>
<p class="text-muted font-size-small mt-0">
Below is a list of all transactions recorded with this category, or any child category.
</p>
<PaginationControls :page="relatedTransactionsPage" @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.
</p>
</div>
</AppPage>
</template>
<style lang="css">
.category-color-indicator {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
border-radius: 50%;
border: 0.2rem solid black;
}
</style>

View File

@ -92,6 +92,11 @@ const router = createRouter({
component: () => import('@/pages/CategoriesPage.vue'), component: () => import('@/pages/CategoriesPage.vue'),
meta: { title: 'Categories' }, meta: { title: 'Categories' },
}, },
{
path: 'categories/:id',
component: () => import('@/pages/CategoryPage.vue'),
meta: { title: 'Category' },
},
], ],
}, },
], ],