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

View File

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

View File

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

View File

@ -12,8 +12,9 @@ The form consists of a few main sections:
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'; import { AccountApiClient, type Account } from '@/api/account';
import { DataApiClient, type Currency } from '@/api/data'; 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 AppPage from '@/components/AppPage.vue';
import CategorySelect from '@/components/CategorySelect.vue';
import AppForm from '@/components/form/AppForm.vue'; import AppForm from '@/components/form/AppForm.vue';
import FormActions from '@/components/form/FormActions.vue'; import FormActions from '@/components/form/FormActions.vue';
import FormControl from '@/components/form/FormControl.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)) return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency === c.code))
}) })
const availableVendors: Ref<TransactionVendor[]> = ref([]) const availableVendors: Ref<TransactionVendor[]> = ref([])
const availableCategories: Ref<TransactionCategoryTree[]> = ref([])
const allAccounts: Ref<Account[]> = ref([]) const allAccounts: Ref<Account[]> = ref([])
const availableAccounts = computed(() => { const availableAccounts = computed(() => {
return allAccounts.value.filter(a => a.currency === currency.value?.code) return allAccounts.value.filter(a => a.currency === currency.value?.code)
@ -77,19 +77,13 @@ watch(availableCurrencies, (newValue: Currency[]) => {
onMounted(async () => { onMounted(async () => {
if (!profileStore.state) return if (!profileStore.state) return
const dataClient = new DataApiClient() const dataClient = new DataApiClient()
const transactionClient = new TransactionApiClient(profileStore.state) const transactionClient = new TransactionApiClient()
const accountClient = new AccountApiClient(profileStore.state) const accountClient = new AccountApiClient(profileStore.state)
// Fetch various collections of data needed for different user choices. // Fetch various collections of data needed for different user choices.
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies) dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
transactionClient.getVendors().then(vendors => availableVendors.value = vendors) transactionClient.getVendors().then(vendors => availableVendors.value = vendors)
transactionClient.getAllTags().then(t => allTags.value = t) 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) 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, * Submits the transaction. If the user is editing an existing transaction,
* then that transaction will be updated. Otherwise, a new transaction is * 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 let savedTransaction = null
try { try {
loading.value = true loading.value = true
@ -291,12 +278,7 @@ function isEdited() {
</select> </select>
</FormControl> </FormControl>
<FormControl label="Category"> <FormControl label="Category">
<select v-model="categoryId" :disabled="loading"> <CategorySelect v-model="categoryId" />
<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>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
@ -320,7 +302,7 @@ function isEdited() {
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<LineItemsEditor v-model="lineItems" :currency="currency" :categories="availableCategories" /> <LineItemsEditor v-model="lineItems" :currency="currency" />
<FormGroup> <FormGroup>
<!-- Tags --> <!-- Tags -->