Added category page.
This commit is contained in:
parent
3368cfa96c
commit
d507aef570
|
|
@ -77,6 +77,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
// Transaction category endpoints:
|
||||
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/children", &handleGetChildCategories);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory);
|
||||
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);
|
||||
}
|
||||
|
||||
void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
auto children = getChildCategories(getProfileDataSource(request), getCategoryId(request));
|
||||
writeJsonBody(response, children);
|
||||
}
|
||||
|
||||
struct CategoryPayload {
|
||||
string name;
|
||||
string description;
|
||||
|
|
|
|||
|
|
@ -341,6 +341,13 @@ TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId)
|
|||
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) {
|
||||
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||
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)
|
||||
}
|
||||
|
||||
getChildCategories(parentId: number): Promise<TransactionCategory[]> {
|
||||
return super.getJson(this.path + '/categories/' + parentId + '/children')
|
||||
}
|
||||
|
||||
createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> {
|
||||
return super.postJson(this.path + '/categories', data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
import type { TransactionCategoryTree } from '@/api/transaction'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
category: TransactionCategoryTree
|
||||
|
|
@ -15,59 +19,30 @@ const expanded = ref(false)
|
|||
const canExpand = computed(() => props.category.children.length > 0)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="category-display-item"
|
||||
:class="{
|
||||
<div class="category-display-item" :class="{
|
||||
'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>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
class="category-display-item-color-indicator"
|
||||
:style="{ 'background-color': '#' + category.color }"
|
||||
></div>
|
||||
<div class="category-display-item-color-indicator" :style="{ 'background-color': '#' + category.color }"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable"
|
||||
style="text-align: right"
|
||||
>
|
||||
<AppButton
|
||||
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 v-if="editable" style="text-align: right">
|
||||
<AppButton 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>
|
||||
<!-- Nested display item for each child: -->
|
||||
<div
|
||||
style="margin-left: 1rem"
|
||||
v-if="canExpand && expanded"
|
||||
>
|
||||
<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 style="margin-left: 1rem" v-if="canExpand && expanded">
|
||||
<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>
|
||||
</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'),
|
||||
meta: { title: 'Categories' },
|
||||
},
|
||||
{
|
||||
path: 'categories/:id',
|
||||
component: () => import('@/pages/CategoryPage.vue'),
|
||||
meta: { title: 'Category' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue