Added categories page and modal to edit categories.

This commit is contained in:
andrewlalis 2025-08-21 19:01:29 -04:00
parent 844f17c80d
commit ae53d7423c
10 changed files with 277 additions and 18 deletions

View File

@ -152,6 +152,7 @@ class SqliteProfileDataSource : ProfileDataSource {
import std.file : exists;
bool needsInit = !exists(path);
this.db = Database(path);
db.run("PRAGMA foreign_keys = ON");
if (needsInit) {
infoF!"Initializing database: %s"(dbPath);
db.run(SCHEMA);

View File

@ -286,6 +286,11 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
}
import std.regex;
const colorHexRegex = ctRegex!`^(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`;
if (payload.color is null || matchFirst(payload.color, colorHexRegex).empty) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
}
auto category = repo.insert(
toOptional(payload.parentId),
payload.name,

View File

@ -110,7 +110,7 @@ CREATE TABLE transaction_line_item (
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE CASCADE
ON UPDATE CASCADE ON DELETE SET NULL
);
CREATE TABLE account_journal_entry (

View File

@ -1,7 +1,7 @@
import { useProfileStore } from '@/stores/profile-store'
import { ApiClient } from './base'
import type { Currency } from './data'
import { type Page, type PageRequest } from './pagination'
import type { Profile } from './profile'
export interface TransactionVendor {
id: number
@ -134,9 +134,11 @@ export interface CreateCategoryPayload {
export class TransactionApiClient extends ApiClient {
readonly path: string
constructor(profile: Profile) {
constructor() {
super()
this.path = `/profiles/${profile.name}`
const profileStore = useProfileStore()
if (!profileStore.state) throw new Error('No profile state!')
this.path = `/profiles/${profileStore.state.name}`
}
getVendors(): Promise<TransactionVendor[]> {
@ -172,7 +174,7 @@ export class TransactionApiClient extends ApiClient {
}
updateCategory(id: number, data: CreateCategoryPayload): Promise<TransactionCategory> {
return super.postJson(this.path + '/categories/' + id, data)
return super.putJson(this.path + '/categories/' + id, data)
}
deleteCategory(id: number): Promise<void> {

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { TransactionCategoryTree } from '@/api/transaction';
import AppButton from './AppButton.vue';
import { computed, ref } from 'vue';
const props = defineProps<{
category: TransactionCategoryTree
editable: boolean
}>()
defineEmits<{
'edited': [number]
'deleted': [number]
}>()
const expanded = ref(false)
const canExpand = computed(() => props.category.children.length > 0)
</script>
<template>
<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>
<p class="category-display-item-description">{{ category.description }}</p>
</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>
<!-- 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>
</div>
</template>
<style lang="css">
.category-display-item {
padding: 0.5rem 1rem;
margin: 1rem 0;
border-radius: 1rem;
}
.category-display-item-content {
display: flex;
justify-content: space-between;
}
.category-display-item-title {
margin: 0;
}
.category-display-item-description {
margin-top: 0.25rem;
margin-bottom: 0;
font-size: 14px;
}
.category-display-item-color-indicator {
width: 30px;
height: 30px;
border-radius: 50%;
border: 0.25rem solid black;
}
.category-display-item-bg-1 {
background-color: var(--bg-primary);
}
.category-display-item-bg-2 {
background-color: var(--bg-secondary);
}
</style>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { TransactionApiClient, type TransactionCategory } from '@/api/transaction';
import ModalWrapper from './ModalWrapper.vue';
import { ref, useTemplateRef, type Ref } from 'vue';
import AppForm from './form/AppForm.vue';
import FormGroup from './form/FormGroup.vue';
import FormControl from './form/FormControl.vue';
import AppButton from './AppButton.vue';
const props = defineProps<{
category?: TransactionCategory
}>()
const emit = defineEmits<{ saved: [TransactionCategory] }>()
const modal = useTemplateRef('modal')
// Form data:
const name = ref('')
const description = ref('')
const color = ref('ffffff')
const parentId: Ref<number | null> = ref(null)
function show(): Promise<string | undefined> {
if (!modal.value) return Promise.resolve(undefined)
name.value = props.category?.name ?? ''
description.value = props.category?.description ?? ''
if (props.category) {
color.value = '#' + props.category.color
} else {
color.value = '#ffffff'
}
parentId.value = props.category?.parentId ?? null
return modal.value.show()
}
function canSave() {
const inputValid = name.value.trim().length > 0 &&
color.value.match('^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$')
if (!inputValid) return false
if (props.category) {
return props.category.name.trim() !== name.value.trim() ||
props.category.description.trim() !== description.value.trim() ||
props.category.color.trim().toLowerCase() !== color.value.trim().toLowerCase() ||
props.category.parentId !== parentId.value
}
return true
}
async function doSave() {
const api = new TransactionApiClient()
const payload = {
name: name.value.trim(),
description: description.value.trim(),
color: color.value.trim().substring(1),
parentId: parentId.value
}
try {
let savedCategory = null
if (props.category) {
savedCategory = await api.updateCategory(props.category.id, payload)
} else {
savedCategory = await api.createCategory(payload)
}
emit('saved', savedCategory)
modal.value?.close('saved')
} catch (err) {
console.error(err)
modal.value?.close()
}
}
defineExpose({ show })
</script>
<template>
<ModalWrapper ref="modal">
<template v-slot:default>
<h2>{{ category ? 'Edit' : 'Add' }} Category</h2>
<AppForm>
<FormGroup>
<FormControl label="Name">
<input type="text" v-model="name" />
</FormControl>
<FormControl label="Color">
<input type="color" v-model="color" />
</FormControl>
<FormControl label="Description" style="min-width: 300px;">
<textarea v-model="description"></textarea>
</FormControl>
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton>
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
</template>
</ModalWrapper>
</template>

View File

@ -5,14 +5,12 @@ import FormControl from './form/FormControl.vue';
import FormGroup from './form/FormGroup.vue';
import ModalWrapper from './ModalWrapper.vue';
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
import { useProfileStore } from '@/stores/profile-store';
import AppButton from './AppButton.vue';
const props = defineProps<{
vendor?: TransactionVendor
}>()
const emit = defineEmits<{ saved: [TransactionVendor] }>()
const profileStore = useProfileStore()
const modal = useTemplateRef('modal')
// Form data:
@ -23,7 +21,7 @@ function show(): Promise<string | undefined> {
if (!modal.value) return Promise.resolve(undefined)
name.value = props.vendor?.name ?? ''
description.value = props.vendor?.description ?? ''
return modal.value?.show()
return modal.value.show()
}
function canSave() {
@ -37,8 +35,7 @@ function canSave() {
}
async function doSave() {
if (!profileStore.state) return
const api = new TransactionApiClient(profileStore.state)
const api = new TransactionApiClient()
const payload = {
name: name.value.trim(),
description: description.value.trim()
@ -63,7 +60,7 @@ defineExpose({ show })
<template>
<ModalWrapper ref="modal">
<template v-slot:default>
<h2>Add Vendor</h2>
<h2>{{ vendor ? 'Edit' : 'Add' }} Vendor</h2>
<AppForm>
<FormGroup>
<FormControl label="Name">
@ -73,7 +70,6 @@ defineExpose({ show })
<textarea v-model="description"></textarea>
</FormControl>
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { TransactionApiClient, type TransactionCategory, type TransactionCategoryTree } from '@/api/transaction';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import CategoryDisplayItem from '@/components/CategoryDisplayItem.vue';
import EditCategoryModal from '@/components/EditCategoryModal.vue';
import { showConfirm } from '@/util/alert';
import { hideLoader, showLoader } from '@/util/loader';
import { nextTick, onMounted, ref, useTemplateRef, type Ref } from 'vue';
const editCategoryModal = useTemplateRef('editCategoryModal')
const categories: Ref<TransactionCategoryTree[]> = ref([])
const editedCategory: Ref<TransactionCategory | undefined> = ref(undefined)
onMounted(async () => {
await loadCategories()
})
async function loadCategories() {
const api = new TransactionApiClient()
categories.value = await api.getCategories()
}
async function editCategory(categoryId: number) {
try {
const api = new TransactionApiClient()
editedCategory.value = await api.getCategory(categoryId)
await nextTick()
const result = await editCategoryModal.value?.show()
if (result === 'saved') {
await loadCategories()
}
} catch (err) {
console.error(err)
}
}
async function deleteCategory(categoryId: number) {
const result = await showConfirm('Are you sure you want to delete this category? It will be removed from all transactions. All sub-categories will also be removed.')
if (result) {
try {
showLoader()
const api = new TransactionApiClient()
await api.deleteCategory(categoryId)
await loadCategories()
} catch (err) {
console.error(err)
} finally {
hideLoader()
}
}
}
async function addCategory() {
editedCategory.value = undefined
const result = await editCategoryModal.value?.show()
if (result === 'saved') {
await loadCategories()
}
}
</script>
<template>
<AppPage title="Categories">
<p>
Categories are used to group related transactions for your own organization,
as well as analytics. Categories are structured hierarchically, where each
category could have zero or more sub-categories.
</p>
<div>
<CategoryDisplayItem v-for="category in categories" :key="category.id" :category="category" :editable="true"
@edited="editCategory" @deleted="deleteCategory" />
</div>
<div style="text-align: right;">
<AppButton icon="plus" @click="addCategory()">Add Category</AppButton>
</div>
<EditCategoryModal ref="editCategoryModal" :category="editedCategory" />
</AppPage>
</template>

View File

@ -3,11 +3,9 @@ import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import EditVendorModal from '@/components/EditVendorModal.vue';
import { useProfileStore } from '@/stores/profile-store';
import { showConfirm } from '@/util/alert';
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
const profileStore = useProfileStore()
const vendors: Ref<TransactionVendor[]> = ref([])
const editVendorModal = useTemplateRef('editVendorModal')
const editedVendor: Ref<TransactionVendor | undefined> = ref()
@ -17,8 +15,7 @@ onMounted(async () => {
})
async function loadVendors() {
if (!profileStore.state) return
const api = new TransactionApiClient(profileStore.state)
const api = new TransactionApiClient()
vendors.value = await api.getVendors()
}
@ -39,10 +36,9 @@ async function editVendor(vendor: TransactionVendor) {
}
async function deleteVendor(vendor: TransactionVendor) {
if (!profileStore.state) return
const confirmed = await showConfirm('Are you sure you want to delete this vendor? It will be permanently removed from all associated transactions.')
if (!confirmed) return
const api = new TransactionApiClient(profileStore.state)
const api = new TransactionApiClient()
await api.deleteVendor(vendor.id)
await loadVendors()
}

View File

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