Added category page.
This commit is contained in:
parent
3368cfa96c
commit
d507aef570
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
'category-display-item-bg-1': category.depth % 2 === 0,
|
||||||
:class="{
|
'category-display-item-bg-2': category.depth % 2 === 1,
|
||||||
'category-display-item-bg-1': category.depth % 2 === 0,
|
}">
|
||||||
'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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue