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;
|
||||
bool needsInit = !exists(path);
|
||||
this.db = Database(path);
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
if (needsInit) {
|
||||
infoF!"Initializing database: %s"(dbPath);
|
||||
db.run(SCHEMA);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue