add add/edit transaction category dialog

This commit is contained in:
MaysWind
2023-08-07 00:55:13 +08:00
parent 1753a6c247
commit c33c0487cf
7 changed files with 601 additions and 13 deletions
+132
View File
@@ -0,0 +1,132 @@
<template>
<v-select
item-title="icon"
item-value="id"
persistent-placeholder
:disabled="disabled"
:label="label"
v-model="color"
@update:menu="onMenuStateChanged"
>
<template #selection="{ item }">
<v-label>
<v-icon :icon="icons.square" :color="`#${item.raw}`" />
</v-label>
</template>
<template #no-data>
<div class="color-select-dropdown" ref="dropdownMenu">
<div class="color-item" :class="{ 'row-has-selected-item': hasSelectedIcon(row) }"
:style="`grid-template-columns: repeat(${itemPerRow}, minmax(0, 1fr));`"
:key="idx" v-for="(row, idx) in allColorRows">
<div class="text-center" :key="colorInfo.color" v-for="colorInfo in row">
<div class="cursor-pointer" @click="color = colorInfo.color">
<v-icon class="ma-2" :icon="icons.square" :color="`#${colorInfo.color}`" v-if="!modelValue || modelValue !== colorInfo.color" />
<v-badge class="right-bottom-icon" color="primary"
location="bottom right" offset-x="8" offset-y="8" :icon="icons.checked"
v-if="modelValue && modelValue === colorInfo.color">
<v-icon class="ma-2" :icon="icons.square" :color="`#${colorInfo.color}`" />
</v-badge>
</div>
</div>
</div>
</div>
</template>
</v-select>
</template>
<script>
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import {
mdiSquareRounded,
mdiCheck
} from '@mdi/js';
export default {
props: [
'modelValue',
'disabled',
'label',
'columnCount',
'allColorInfos'
],
emits: [
'update:modelValue',
],
data() {
const self = this;
return {
itemPerRow: self.columnCount || 7,
icons: {
square: mdiSquareRounded,
checked: mdiCheck
}
}
},
computed: {
allColorRows() {
const ret = [];
let rowCount = -1;
for (let i = 0; i < this.allColorInfos.length; i++) {
if (i % this.itemPerRow === 0) {
ret[++rowCount] = [];
}
ret[rowCount].push({
color: this.allColorInfos[i]
});
}
return ret;
},
color: {
get: function () {
return this.modelValue;
},
set: function (value) {
this.$emit('update:modelValue', value);
}
}
},
methods: {
hasSelectedIcon(row) {
if (!this.modelValue || !row || !row.length) {
return false;
}
for (let i = 0; i < row.length; i++) {
if (row[i].id === this.modelValue) {
return true;
}
}
return false;
},
onMenuStateChanged(state) {
const self = this;
if (state) {
self.$nextTick(() => {
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
}
});
}
}
}
}
</script>
<style>
.color-select-dropdown {
padding-left: 8px;
padding-right: 8px;
}
.color-select-dropdown .color-item {
display: grid;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<v-select
item-title="icon"
item-value="id"
persistent-placeholder
:disabled="disabled"
:label="label"
v-model="icon"
@update:menu="onMenuStateChanged"
>
<template #selection="{ item }">
<v-label>
<ItemIcon icon-type="category" size="23px" :icon-id="icon" :color="color" />
</v-label>
</template>
<template #no-data>
<div class="icon-select-dropdown" ref="dropdownMenu">
<div class="icon-item" :class="{ 'row-has-selected-item': hasSelectedIcon(row) }"
:style="`grid-template-columns: repeat(${itemPerRow}, minmax(0, 1fr));`"
:key="idx" v-for="(row, idx) in allIconRows">
<div class="text-center" :key="iconInfo.id" v-for="iconInfo in row">
<div class="cursor-pointer" @click="icon = iconInfo.id">
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" v-if="!modelValue || modelValue !== iconInfo.id" />
<v-badge class="right-bottom-icon" color="primary"
location="bottom right" offset-x="8" offset-y="10" :icon="icons.checked"
v-if="modelValue && modelValue === iconInfo.id">
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" />
</v-badge>
</div>
</div>
</div>
</div>
</template>
</v-select>
</template>
<script>
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import {
mdiCheck
} from '@mdi/js';
export default {
props: [
'modelValue',
'disabled',
'label',
'color',
'columnCount',
'allIconInfos'
],
emits: [
'update:modelValue',
],
data() {
const self = this;
return {
itemPerRow: self.columnCount || 7,
icons: {
checked: mdiCheck
}
}
},
computed: {
allIconRows() {
const ret = [];
let rowCount = 0;
for (let iconInfoId in this.allIconInfos) {
if (!Object.prototype.hasOwnProperty.call(this.allIconInfos, iconInfoId)) {
continue;
}
const iconInfo = this.allIconInfos[iconInfoId];
if (!ret[rowCount]) {
ret[rowCount] = [];
} else if (ret[rowCount] && ret[rowCount].length >= this.itemPerRow) {
rowCount++;
ret[rowCount] = [];
}
ret[rowCount].push({
id: iconInfoId,
icon: iconInfo.icon
});
}
return ret;
},
icon: {
get: function () {
return this.modelValue;
},
set: function (value) {
this.$emit('update:modelValue', value);
}
}
},
methods: {
hasSelectedIcon(row) {
if (!this.modelValue || !row || !row.length) {
return false;
}
for (let i = 0; i < row.length; i++) {
if (row[i].id === this.modelValue) {
return true;
}
}
return false;
},
onMenuStateChanged(state) {
const self = this;
if (state) {
self.$nextTick(() => {
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
}
});
}
}
}
}
</script>
<style>
.icon-select-dropdown {
padding-left: 8px;
padding-right: 8px;
}
.icon-select-dropdown .icon-item {
display: grid;
}
</style>
+6
View File
@@ -39,6 +39,7 @@ import { VSnackbar } from 'vuetify/components/VSnackbar';
import { VSwitch } from 'vuetify/components/VSwitch';
import { VTabs, VTab } from 'vuetify/components/VTabs';
import { VTable } from 'vuetify/components/VTable';
import { VTextarea } from 'vuetify/components/VTextarea';
import { VTextField } from 'vuetify/components/VTextField';
import { VToolbar } from 'vuetify/components/VToolbar';
import { VTooltip } from 'vuetify/components/VTooltip';
@@ -82,6 +83,8 @@ import PinCodeInput from '@/components/common/PinCodeInput.vue';
import ItemIcon from '@/components/desktop/ItemIcon.vue';
import BtnVerticalGroup from '@/components/desktop/BtnVerticalGroup.vue';
import AmountInput from '@/components/desktop/AmountInput.vue';
import ColorSelect from '@/components/desktop/ColorSelect.vue';
import IconSelect from '@/components/desktop/IconSelect.vue';
import StepsBar from '@/components/desktop/StepsBar.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
@@ -162,6 +165,7 @@ const vuetify = createVuetify({
VTabs,
VTab,
VTable,
VTextarea,
VTextField,
VToolbar,
VTooltip,
@@ -392,6 +396,8 @@ app.component('PinCodeInput', PinCodeInput);
app.component('ItemIcon', ItemIcon);
app.component('BtnVerticalGroup', BtnVerticalGroup);
app.component('AmountInput', AmountInput);
app.component('ColorSelect', ColorSelect);
app.component('IconSelect', IconSelect);
app.component('StepsBar', StepsBar);
app.component('ConfirmDialog', ConfirmDialog);
app.component('SnackBar', SnackBar);
+10 -5
View File
@@ -19,19 +19,24 @@ export function getCssValue(element, name) {
return computedStyle.getPropertyValue(name);
}
export function scrollToMenuListItem(listContentEl) {
if (!listContentEl) {
export function scrollToSelectedItem(parentEl, containerSelector, selectedItemSelector) {
if (!parentEl) {
return;
}
const lists = listContentEl.querySelectorAll('div.v-list');
let container = parentEl;
if (containerSelector) {
const lists = parentEl.querySelectorAll(containerSelector);
if (!lists.length || !lists[0]) {
return;
}
const container = lists[0];
const selectedItems = container.querySelectorAll('div.v-list-item.list-item-selected');
container = lists[0];
}
const selectedItems = container.querySelectorAll(selectedItemSelector);
if (!selectedItems.length || !selectedItems[0]) {
return;
+33 -4
View File
@@ -170,7 +170,8 @@
</v-col>
</v-row>
<preset-category-dialog :category-type="activeCategoryType" v-model:show="showPresetDialog"
<edit-dialog ref="editDialog" :persistent="true" />
<preset-dialog :category-type="activeCategoryType" v-model:show="showPresetDialog"
@category:saved="presetCategorySaved" />
<confirm-dialog ref="confirmDialog"/>
@@ -178,7 +179,8 @@
</template>
<script>
import PresetCategoryDialog from './list/dialogs/PresetDialog.vue';
import EditDialog from './list/dialogs/EditDialog.vue';
import PresetDialog from './list/dialogs/PresetDialog.vue';
import { useDisplay } from 'vuetify';
@@ -201,7 +203,8 @@ import {
export default {
components: {
PresetCategoryDialog
EditDialog,
PresetDialog
},
data() {
const { mdAndUp } = useDisplay();
@@ -376,10 +379,36 @@ export default {
});
},
add() {
const self = this;
self.$refs.editDialog.open({
type: self.activeCategoryType,
parentId: self.primaryCategoryId
}).then(result => {
if (result && result.message) {
self.$refs.snackbar.showMessage(result.message);
}
}).catch(error => {
if (error) {
self.$refs.snackbar.showError(error);
}
});
},
edit() {
edit(category) {
const self = this;
self.$refs.editDialog.open({
id: category.id,
currentCategory: category
}).then(result => {
if (result && result.message) {
self.$refs.snackbar.showMessage(result.message);
}
}).catch(error => {
if (error) {
self.$refs.snackbar.showError(error);
}
});
},
hide(category, hidden) {
const self = this;
@@ -0,0 +1,275 @@
<template>
<v-dialog width="600" :persistent="!!persistent" v-model="showState">
<v-card>
<v-toolbar color="primary">
<v-toolbar-title>
<span>{{ $t(title) }}</span>
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</v-toolbar-title>
</v-toolbar>
<v-card-text class="pa-4 mt-2">
<v-row>
<v-col cols="12" md="12">
<v-text-field
type="text"
clearable
persistent-placeholder
:disabled="loading || submitting"
:label="$t('Category Name')"
:placeholder="$t('Category Name')"
v-model="category.name"
/>
</v-col>
<v-col cols="12" md="6">
<icon-select :all-icon-infos="allCategoryIcons"
:label="$t('Category Icon')"
:color="category.color"
:disabled="loading || submitting"
v-model="category.icon" />
</v-col>
<v-col cols="12" md="6">
<color-select :all-color-infos="allCategoryColors"
:label="$t('Category Color')"
:disabled="loading || submitting"
v-model="category.color" />
</v-col>
<v-col cols="12" md="12">
<v-textarea
type="text"
persistent-placeholder
:disabled="loading || submitting"
:label="$t('Description')"
:placeholder="$t('Your category description (optional)')"
v-model="category.comment"
/>
</v-col>
<v-col class="pt-0" cols="12" md="12" v-if="editCategoryId">
<v-switch inset :disabled="loading || submitting"
:label="$t('Visible')" v-model="category.visible"/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" :disabled="loading || submitting" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="inputIsEmpty || loading || submitting" @click="save">
{{ $t(saveButtonTitle) }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import categoryConstants from '@/consts/category.js';
import iconConstants from '@/consts/icon.js';
import colorConstants from '@/consts/color.js';
export default {
props: [
'persistent',
'show'
],
expose: [
'open'
],
data() {
return {
showState: false,
editCategoryId: null,
loading: false,
category: {
type: categoryConstants.allCategoryTypes.Income,
name: '',
parentId: '0',
icon: iconConstants.defaultCategoryIconId,
color: colorConstants.defaultCategoryColor,
comment: '',
visible: true
},
submitting: false,
resolve: null,
reject: null
};
},
computed: {
...mapStores(useTransactionCategoriesStore),
title() {
if (!this.editCategoryId) {
if (this.category.parentId === '0') {
return 'Add Primary Category';
} else {
return 'Add Secondary Category';
}
} else {
return 'Edit Category';
}
},
saveButtonTitle() {
if (!this.editCategoryId) {
return 'Add';
} else {
return 'Save';
}
},
allCategoryIcons() {
return iconConstants.allCategoryIcons;
},
allCategoryColors() {
return colorConstants.allCategoryColors;
},
inputIsEmpty() {
return !!this.inputEmptyProblemMessage;
},
inputEmptyProblemMessage() {
if (!this.category.name) {
return 'Category name cannot be empty';
} else {
return null;
}
}
},
methods: {
open(options) {
const self = this;
self.showState = true;
self.loading = true;
self.submitting = false;
self.category.id = null;
self.category.type = categoryConstants.allCategoryTypes.Income;
self.category.parentId = '0';
self.category.name = '';
self.category.icon = iconConstants.defaultCategoryIconId;
self.category.color = colorConstants.defaultCategoryColor;
self.category.comment = '';
self.category.visible = true;
if (options.id) {
if (options.currentCategory) {
self.setCategory(options.currentCategory);
}
self.editCategoryId = options.id;
self.transactionCategoriesStore.getCategory({
categoryId: self.editCategoryId
}).then(category => {
self.setCategory(category);
self.loading = false;
}).catch(error => {
self.loading = false;
self.showState = false;
if (!error.processed) {
if (self.reject) {
self.reject(error);
}
}
});
} else if (options.parentId) {
self.editCategoryId = null;
const categoryType = parseInt(options.type);
if (categoryType !== categoryConstants.allCategoryTypes.Income &&
categoryType !== categoryConstants.allCategoryTypes.Expense &&
categoryType !== categoryConstants.allCategoryTypes.Transfer) {
self.loading = false;
self.showState = false;
if (self.reject) {
self.reject('Parameter Invalid');
}
return;
}
self.category.type = categoryType;
self.category.parentId = options.parentId;
self.loading = false;
}
return new Promise((resolve, reject) => {
self.resolve = resolve;
self.reject = reject;
});
},
save() {
const self = this;
const problemMessage = self.inputEmptyProblemMessage;
if (problemMessage) {
self.$refs.snackbar.showMessage(problemMessage);
return;
}
self.submitting = true;
const submitCategory = {
type: self.category.type,
name: self.category.name,
parentId: self.category.parentId,
icon: self.category.icon,
color: self.category.color,
comment: self.category.comment
};
if (self.editCategoryId) {
submitCategory.id = self.category.id;
submitCategory.hidden = !self.category.visible;
}
self.transactionCategoriesStore.saveCategory({
category: submitCategory
}).then(() => {
self.submitting = false;
let message = 'You have saved this category';
if (!self.editCategoryId) {
message = 'You have added a new category';
}
if (self.resolve) {
self.resolve({
message: message
});
}
self.showState = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
cancel() {
if (this.reject) {
this.reject();
}
this.showState = false;
},
setCategory(category) {
this.category.id = category.id;
this.category.type = category.type;
this.category.parentId = category.type.parentId;
this.category.name = category.name;
this.category.icon = category.icon;
this.category.color = category.color;
this.category.comment = category.comment;
this.category.visible = !category.hidden;
}
}
}
</script>
+2 -2
View File
@@ -351,7 +351,7 @@ import {
categoryTypeToTransactionType,
transactionTypeToCategoryType
} from '@/lib/category.js';
import { scrollToMenuListItem } from '@/lib/ui.desktop.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import {
mdiMagnify,
@@ -816,7 +816,7 @@ export default {
},
scrollMenuToSelectedItem(menu) {
this.$nextTick(() => {
scrollToMenuListItem(menu.contentEl);
scrollToSelectedItem(menu.contentEl, 'div.v-list', 'div.v-list-item.list-item-selected');
});
},
getDisplayTime(transaction) {