Added category select.
Build and Deploy Web App / build-and-deploy (push) Failing after 11s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m24s Details

This commit is contained in:
andrewlalis 2025-08-22 21:05:05 -04:00
parent ae53d7423c
commit ca87d2723c
6 changed files with 70 additions and 38 deletions

View File

@ -165,6 +165,20 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/categories')
}
async getCategoriesFlattened(): Promise<TransactionCategoryTree[]> {
const categories = await this.getCategories()
const flat: TransactionCategoryTree[] = []
await this.flattenCategories(flat, categories)
return flat
}
private flattenCategories(arr: TransactionCategoryTree[], tree: TransactionCategoryTree[]) {
for (const category of tree) {
arr.push(category)
this.flattenCategories(arr, category.children)
}
}
getCategory(id: number): Promise<TransactionCategory> {
return super.getJson(this.path + '/categories/' + id)
}

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { TransactionApiClient, type TransactionCategoryTree } from '@/api/transaction';
import { onMounted, ref, type Ref } from 'vue';
const model = defineModel<number | null>({ required: true })
defineProps<{ required?: boolean }>()
defineEmits<{ categorySelected: [TransactionCategoryTree | null] }>()
const categories: Ref<TransactionCategoryTree[]> = ref([])
onMounted(() => {
const api = new TransactionApiClient()
api.getCategoriesFlattened()
.then(c => categories.value = c)
})
function getCategoryById(id: number): TransactionCategoryTree | null {
for (const c of categories.value) {
if (c.id === id) return c
}
return null
}
</script>
<template>
<select v-model="model" @change="$emit('categorySelected', model === null ? null : getCategoryById(model))">
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ "&nbsp;".repeat(4 * category.depth) + category.name }}
</option>
<option v-if="required !== true" :value="null" :selected="model === null">None</option>
</select>
</template>

View File

@ -6,6 +6,7 @@ import AppForm from './form/AppForm.vue';
import FormGroup from './form/FormGroup.vue';
import FormControl from './form/FormControl.vue';
import AppButton from './AppButton.vue';
import CategorySelect from './CategorySelect.vue';
const props = defineProps<{
category?: TransactionCategory
@ -24,11 +25,16 @@ function show(): Promise<string | undefined> {
name.value = props.category?.name ?? ''
description.value = props.category?.description ?? ''
if (props.category) {
name.value = props.category.name
description.value = props.category.description
color.value = '#' + props.category.color
parentId.value = props.category.parentId
} else {
name.value = ''
description.value = ''
color.value = '#ffffff'
parentId.value = null
}
parentId.value = props.category?.parentId ?? null
return modal.value.show()
}
@ -85,6 +91,9 @@ defineExpose({ show })
<FormControl label="Description" style="min-width: 300px;">
<textarea v-model="description"></textarea>
</FormControl>
<FormControl label="Parent Category">
<CategorySelect v-model="parentId"></CategorySelect>
</FormControl>
</FormGroup>
</AppForm>
</template>

View File

@ -11,20 +11,20 @@ import { formatMoney, type Currency } from '@/api/data';
import ModalWrapper from './ModalWrapper.vue';
import FormControl from './form/FormControl.vue';
import { ref, type Ref, useTemplateRef } from 'vue';
import CategorySelect from './CategorySelect.vue';
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
defineProps<{
currency: Currency | null,
categories: TransactionCategoryTree[]
currency: Currency | null
}>()
const addLineItemDescription = ref('')
const addLineItemValuePerItem = ref(0)
const addLineItemQuantity = ref(0)
const addLineItemCategory: Ref<TransactionCategoryTree | null> = ref(null)
const addLineItemCategoryId: Ref<number | null> = ref(null)
const selectedCategory: Ref<TransactionCategoryTree | null> = ref(null)
const addLineItemModal = useTemplateRef('addLineItemModal')
function canAddLineItem() {
return addLineItemDescription.value.length > 0 &&
addLineItemQuantity.value > 0
@ -34,11 +34,11 @@ function showAddLineItemModal() {
addLineItemDescription.value = ''
addLineItemValuePerItem.value = 1.00
addLineItemQuantity.value = 1
addLineItemCategory.value = null
addLineItemCategoryId.value = null
addLineItemModal.value?.show()
}
function addLineItem() {
async function addLineItem() {
const idxs: number[] = model.value.map(i => i.idx)
const newIdx = Math.max(...idxs)
model.value.push({
@ -46,7 +46,7 @@ function addLineItem() {
description: addLineItemDescription.value,
quantity: addLineItemQuantity.value,
valuePerItem: addLineItemValuePerItem.value,
category: addLineItemCategory.value
category: selectedCategory.value
})
addLineItemModal.value?.close()
}
@ -104,12 +104,7 @@ function removeLineItem(idx: number) {
<input type="number" step="1" min="1" v-model="addLineItemQuantity" />
</FormControl>
<FormControl label="Category">
<select v-model="addLineItemCategory">
<option v-for="category in categories" :key="category.id" :value="category">
{{ "&nbsp;".repeat(4 * category.depth) + category.name }}
</option>
<option :value="null">None</option>
</select>
<CategorySelect v-model="addLineItemCategoryId" @category-selected="c => selectedCategory = c" />
</FormControl>
</FormGroup>
</template>

View File

@ -54,6 +54,7 @@ async function deleteCategory(categoryId: number) {
async function addCategory() {
editedCategory.value = undefined
await nextTick()
const result = await editCategoryModal.value?.show()
if (result === 'saved') {
await loadCategories()

View File

@ -12,8 +12,9 @@ The form consists of a few main sections:
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account';
import { DataApiClient, type Currency } from '@/api/data';
import { TransactionApiClient, type AddTransactionPayload, type TransactionCategoryTree, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
import AppPage from '@/components/AppPage.vue';
import CategorySelect from '@/components/CategorySelect.vue';
import AppForm from '@/components/form/AppForm.vue';
import FormActions from '@/components/form/FormActions.vue';
import FormControl from '@/components/form/FormControl.vue';
@ -38,7 +39,6 @@ const availableCurrencies = computed(() => {
return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency === c.code))
})
const availableVendors: Ref<TransactionVendor[]> = ref([])
const availableCategories: Ref<TransactionCategoryTree[]> = ref([])
const allAccounts: Ref<Account[]> = ref([])
const availableAccounts = computed(() => {
return allAccounts.value.filter(a => a.currency === currency.value?.code)
@ -77,19 +77,13 @@ watch(availableCurrencies, (newValue: Currency[]) => {
onMounted(async () => {
if (!profileStore.state) return
const dataClient = new DataApiClient()
const transactionClient = new TransactionApiClient(profileStore.state)
const transactionClient = new TransactionApiClient()
const accountClient = new AccountApiClient(profileStore.state)
// Fetch various collections of data needed for different user choices.
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
transactionClient.getVendors().then(vendors => availableVendors.value = vendors)
transactionClient.getAllTags().then(t => allTags.value = t)
transactionClient.getCategories().then(categories => {
// Flatten the recursive list of categories.
const flattened: TransactionCategoryTree[] = []
flattenCategories(flattened, categories)
availableCategories.value = flattened
})
accountClient.getAccounts().then(accounts => allAccounts.value = accounts)
@ -108,13 +102,6 @@ onMounted(async () => {
}
})
function flattenCategories(arr: TransactionCategoryTree[], tree: TransactionCategoryTree[]) {
for (const category of tree) {
arr.push(category)
flattenCategories(arr, category.children)
}
}
/**
* Submits the transaction. If the user is editing an existing transaction,
* then that transaction will be updated. Otherwise, a new transaction is
@ -140,7 +127,7 @@ async function doSubmit() {
})
}
const transactionApi = new TransactionApiClient(profileStore.state)
const transactionApi = new TransactionApiClient()
let savedTransaction = null
try {
loading.value = true
@ -291,12 +278,7 @@ function isEdited() {
</select>
</FormControl>
<FormControl label="Category">
<select v-model="categoryId" :disabled="loading">
<option v-for="category in availableCategories" :key="category.id" :value="category.id">
{{ "&nbsp;".repeat(4 * category.depth) + category.name }}
</option>
<option :value="null" :selected="categoryId === null">None</option>
</select>
<CategorySelect v-model="categoryId" />
</FormControl>
</FormGroup>
@ -320,7 +302,7 @@ function isEdited() {
</FormControl>
</FormGroup>
<LineItemsEditor v-model="lineItems" :currency="currency" :categories="availableCategories" />
<LineItemsEditor v-model="lineItems" :currency="currency" />
<FormGroup>
<!-- Tags -->