Added category select.
This commit is contained in:
parent
ae53d7423c
commit
ca87d2723c
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{{ " ".repeat(4 * category.depth) + category.name }}
|
||||||
|
</option>
|
||||||
|
<option v-if="required !== true" :value="null" :selected="model === null">None</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
|
||||||
{{ " ".repeat(4 * category.depth) + category.name }}
|
|
||||||
</option>
|
|
||||||
<option :value="null">None</option>
|
|
||||||
</select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">
|
|
||||||
{{ " ".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 -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue