Added categories page and modal to edit categories.
This commit is contained in:
parent
844f17c80d
commit
ae53d7423c
|
|
@ -152,6 +152,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
import std.file : exists;
|
import std.file : exists;
|
||||||
bool needsInit = !exists(path);
|
bool needsInit = !exists(path);
|
||||||
this.db = Database(path);
|
this.db = Database(path);
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
if (needsInit) {
|
if (needsInit) {
|
||||||
infoF!"Initializing database: %s"(dbPath);
|
infoF!"Initializing database: %s"(dbPath);
|
||||||
db.run(SCHEMA);
|
db.run(SCHEMA);
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,11 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl
|
||||||
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
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(
|
auto category = repo.insert(
|
||||||
toOptional(payload.parentId),
|
toOptional(payload.parentId),
|
||||||
payload.name,
|
payload.name,
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ CREATE TABLE transaction_line_item (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_transaction_line_item_category
|
CONSTRAINT fk_transaction_line_item_category
|
||||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
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 (
|
CREATE TABLE account_journal_entry (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { useProfileStore } from '@/stores/profile-store'
|
||||||
import { ApiClient } from './base'
|
import { ApiClient } from './base'
|
||||||
import type { Currency } from './data'
|
import type { Currency } from './data'
|
||||||
import { type Page, type PageRequest } from './pagination'
|
import { type Page, type PageRequest } from './pagination'
|
||||||
import type { Profile } from './profile'
|
|
||||||
|
|
||||||
export interface TransactionVendor {
|
export interface TransactionVendor {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -134,9 +134,11 @@ export interface CreateCategoryPayload {
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
constructor(profile: Profile) {
|
constructor() {
|
||||||
super()
|
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[]> {
|
getVendors(): Promise<TransactionVendor[]> {
|
||||||
|
|
@ -172,7 +174,7 @@ export class TransactionApiClient extends ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(id: number, data: CreateCategoryPayload): Promise<TransactionCategory> {
|
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> {
|
deleteCategory(id: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -5,14 +5,12 @@ import FormControl from './form/FormControl.vue';
|
||||||
import FormGroup from './form/FormGroup.vue';
|
import FormGroup from './form/FormGroup.vue';
|
||||||
import ModalWrapper from './ModalWrapper.vue';
|
import ModalWrapper from './ModalWrapper.vue';
|
||||||
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
|
||||||
import AppButton from './AppButton.vue';
|
import AppButton from './AppButton.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
vendor?: TransactionVendor
|
vendor?: TransactionVendor
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ saved: [TransactionVendor] }>()
|
const emit = defineEmits<{ saved: [TransactionVendor] }>()
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const modal = useTemplateRef('modal')
|
const modal = useTemplateRef('modal')
|
||||||
|
|
||||||
// Form data:
|
// Form data:
|
||||||
|
|
@ -23,7 +21,7 @@ function show(): Promise<string | undefined> {
|
||||||
if (!modal.value) return Promise.resolve(undefined)
|
if (!modal.value) return Promise.resolve(undefined)
|
||||||
name.value = props.vendor?.name ?? ''
|
name.value = props.vendor?.name ?? ''
|
||||||
description.value = props.vendor?.description ?? ''
|
description.value = props.vendor?.description ?? ''
|
||||||
return modal.value?.show()
|
return modal.value.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
function canSave() {
|
function canSave() {
|
||||||
|
|
@ -37,8 +35,7 @@ function canSave() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave() {
|
async function doSave() {
|
||||||
if (!profileStore.state) return
|
const api = new TransactionApiClient()
|
||||||
const api = new TransactionApiClient(profileStore.state)
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
description: description.value.trim()
|
description: description.value.trim()
|
||||||
|
|
@ -63,7 +60,7 @@ defineExpose({ show })
|
||||||
<template>
|
<template>
|
||||||
<ModalWrapper ref="modal">
|
<ModalWrapper ref="modal">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<h2>Add Vendor</h2>
|
<h2>{{ vendor ? 'Edit' : 'Add' }} Vendor</h2>
|
||||||
<AppForm>
|
<AppForm>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControl label="Name">
|
<FormControl label="Name">
|
||||||
|
|
@ -73,7 +70,6 @@ defineExpose({ show })
|
||||||
<textarea v-model="description"></textarea>
|
<textarea v-model="description"></textarea>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
</AppForm>
|
</AppForm>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:buttons>
|
<template v-slot:buttons>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,11 +3,9 @@ import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
|
||||||
import AppButton from '@/components/AppButton.vue';
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import AppPage from '@/components/AppPage.vue';
|
import AppPage from '@/components/AppPage.vue';
|
||||||
import EditVendorModal from '@/components/EditVendorModal.vue';
|
import EditVendorModal from '@/components/EditVendorModal.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
|
||||||
import { showConfirm } from '@/util/alert';
|
import { showConfirm } from '@/util/alert';
|
||||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||||
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const vendors: Ref<TransactionVendor[]> = ref([])
|
const vendors: Ref<TransactionVendor[]> = ref([])
|
||||||
const editVendorModal = useTemplateRef('editVendorModal')
|
const editVendorModal = useTemplateRef('editVendorModal')
|
||||||
const editedVendor: Ref<TransactionVendor | undefined> = ref()
|
const editedVendor: Ref<TransactionVendor | undefined> = ref()
|
||||||
|
|
@ -17,8 +15,7 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadVendors() {
|
async function loadVendors() {
|
||||||
if (!profileStore.state) return
|
const api = new TransactionApiClient()
|
||||||
const api = new TransactionApiClient(profileStore.state)
|
|
||||||
vendors.value = await api.getVendors()
|
vendors.value = await api.getVendors()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,10 +36,9 @@ async function editVendor(vendor: TransactionVendor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteVendor(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.')
|
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
|
if (!confirmed) return
|
||||||
const api = new TransactionApiClient(profileStore.state)
|
const api = new TransactionApiClient()
|
||||||
await api.deleteVendor(vendor.id)
|
await api.deleteVendor(vendor.id)
|
||||||
await loadVendors()
|
await loadVendors()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/VendorsPage.vue'),
|
component: () => import('@/pages/VendorsPage.vue'),
|
||||||
meta: { title: 'Vendors' },
|
meta: { title: 'Vendors' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'categories',
|
||||||
|
component: () => import('@/pages/CategoriesPage.vue'),
|
||||||
|
meta: { title: 'Categories' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue