add transaction template

This commit is contained in:
MaysWind
2024-07-29 00:53:19 +08:00
parent 4c69243bef
commit de086aa29e
25 changed files with 2109 additions and 44 deletions
+7
View File
@@ -0,0 +1,7 @@
const allTemplateTypes = {
Normal: 1
};
export default {
allTemplateTypes: allTemplateTypes,
}
+8
View File
@@ -37,6 +37,10 @@ export function categoryTypeToTransactionType(categoryType) {
}
export function getTransactionPrimaryCategoryName(categoryId, allCategories) {
if (!allCategories) {
return '';
}
for (let i = 0; i < allCategories.length; i++) {
for (let j = 0; j < allCategories[i].subCategories.length; j++) {
const subCategory = allCategories[i].subCategories[j];
@@ -50,6 +54,10 @@ export function getTransactionPrimaryCategoryName(categoryId, allCategories) {
}
export function getTransactionSecondaryCategoryName(categoryId, allCategories) {
if (!allCategories) {
return '';
}
for (let i = 0; i < allCategories.length; i++) {
for (let j = 0; j < allCategories[i].subCategories.length; j++) {
const subCategory = allCategories[i].subCategories[j];
+48
View File
@@ -517,6 +517,54 @@ export default {
id
});
},
getAllTransactionTemplates: ({ templateType }) => {
return axios.get('v1/transaction/templates/list.json?templateType=' + templateType);
},
getTransactionTemplate: ({ id }) => {
return axios.get('v1/transaction/templates/get.json?id=' + id);
},
addTransactionTemplate: ({ templateType, name, clientSessionId }) => {
return axios.post('v1/transaction/templates/add.json', {
templateType,
name,
clientSessionId
});
},
modifyTransactionNameTemplate: ({ id, name }) => {
return axios.post('v1/transaction/templates/modify_name.json', {
id,
name
});
},
modifyTransactionTemplate: ({ id, type, categoryId, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, tagIds, comment }) => {
return axios.post('v1/transaction/templates/modify.json', {
id,
type,
categoryId,
sourceAccountId,
destinationAccountId,
sourceAmount,
destinationAmount,
tagIds,
comment
});
},
hideTransactionTemplate: ({ id, hidden }) => {
return axios.post('v1/transaction/templates/hide.json', {
id,
hidden
});
},
moveTransactionTemplate: ({ newDisplayOrders }) => {
return axios.post('v1/transaction/templates/move.json', {
newDisplayOrders,
});
},
deleteTransactionTemplate: ({ id }) => {
return axios.post('v1/transaction/templates/delete.json', {
id
});
},
getLatestExchangeRates: ({ ignoreError }) => {
return axios.get('v1/exchange_rates/latest.json', {
ignoreError: !!ignoreError
+19
View File
@@ -788,6 +788,7 @@ export default {
'Show': 'Show',
'Hide': 'Hide',
'Version': 'Version',
'Modify Name': 'Modify Name',
'Edit': 'Edit',
'Remove': 'Remove',
'Delete': 'Delete',
@@ -1020,6 +1021,7 @@ export default {
'Transactions': 'Transactions',
'Add Transaction': 'Add Transaction',
'Edit Transaction': 'Edit Transaction',
'Edit Transaction Template': 'Edit Transaction Template',
'Modify Balance': 'Modify Balance',
'Expense Amount': 'Expense Amount',
'Income Amount': 'Income Amount',
@@ -1268,6 +1270,23 @@ export default {
'Unable to delete this tag': 'Unable to delete this tag',
'Show Hidden Transaction Tags': 'Show Hidden Transaction Tags',
'Hide Hidden Transaction Tags': 'Hide Hidden Transaction Tags',
'Transaction Templates': 'Transaction Templates',
'Template Name': 'Template Name',
'No available template': 'No available template',
'Unable to retrieve template list': 'Unable to retrieve template list',
'Template list is up to date': 'Template list is up to date',
'Template list has been updated': 'Template list has been updated',
'Unable to add template': 'Unable to add template',
'Unable to save template': 'Unable to save template',
'Unable to move template': 'Unable to move template',
'Unable to retrieve template': 'Unable to retrieve template',
'Unable to hide this template': 'Unable to hide this template',
'Unable to unhide this template': 'Unable to unhide this template',
'Are you sure you want to delete this template?': 'Are you sure you want to delete this template?',
'Unable to delete this template': 'Unable to delete this template',
'You have saved this template': 'You have saved this template',
'Show Hidden Templates': 'Show Hidden Transaction Templates',
'Hide Hidden Templates': 'Hide Hidden Transaction Templates',
'Are you sure you want to logout from this session?': 'Are you sure you want to logout from this session?',
'Unable to logout from this session': 'Unable to logout from this session',
'Are you sure you want to logout all other sessions?': 'Are you sure you want to logout all other sessions?',
+19
View File
@@ -788,6 +788,7 @@ export default {
'Show': '显示',
'Hide': '隐藏',
'Version': '版本',
'Modify Name': '修改名称',
'Edit': '编辑',
'Remove': '移除',
'Delete': '删除',
@@ -1020,6 +1021,7 @@ export default {
'Transactions': '交易',
'Add Transaction': '添加交易',
'Edit Transaction': '编辑交易',
'Edit Transaction Template': '编辑交易模板',
'Modify Balance': '修改余额',
'Expense Amount': '支出金额',
'Income Amount': '收入金额',
@@ -1268,6 +1270,23 @@ export default {
'Unable to delete this tag': '无法删除该标签',
'Show Hidden Transaction Tags': '显示隐藏的交易标签',
'Hide Hidden Transaction Tags': '不显示隐藏的交易标签',
'Transaction Templates': '交易模板',
'Template Name': '模板名称',
'No available template': '没有可用的模板',
'Unable to retrieve template list': '无法获取模板列表',
'Template list is up to date': '模板列表已是最新',
'Template list has been updated': '模板列表已更新',
'Unable to add template': '无法添加模板',
'Unable to save template': '无法保存模板',
'Unable to move template': '无法移动模板',
'Unable to retrieve template': '无法获取模板',
'Unable to hide this template': '无法隐藏该模板',
'Unable to unhide this template': '无法取消隐藏该模板',
'Are you sure you want to delete this template?': '您确定要删除该模板?',
'Unable to delete this template': '无法删除该模板',
'You have saved this template': '您已经保存该模板',
'Show Hidden Templates': '显示隐藏的模板',
'Hide Hidden Templates': '不显示隐藏的模板',
'Are you sure you want to logout from this session?': '您确定要退出该会话?',
'Unable to logout from this session': '无法退出该会话',
'Are you sure you want to logout all other sessions?': '您确定要退出其他所有会话?',
+7
View File
@@ -22,6 +22,8 @@ import TransactionCategoryListPage from '@/views/desktop/categories/ListPage.vue
import TransactionTagListPage from '@/views/desktop/tags/ListPage.vue';
import TransactionTemplateListPage from '@/views/desktop/templates/ListPage.vue';
import UserSettingsPage from '@/views/desktop/user/UserSettingsPage.vue';
import AppSettingsPage from '@/views/desktop/app/AppSettingsPage.vue';
@@ -136,6 +138,11 @@ const router = createRouter({
component: TransactionTagListPage,
beforeEnter: checkLogin
},
{
path: '/template/list',
component: TransactionTemplateListPage,
beforeEnter: checkLogin
},
{
path: '/exchange_rates',
component: ExchangeRatesPage,
+7
View File
@@ -38,6 +38,8 @@ import CategoryPresetPage from '@/views/mobile/categories/PresetPage.vue';
import TagListPage from '@/views/mobile/tags/ListPage.vue';
import TemplateListPage from '@/views/mobile/templates/ListPage.vue';
function asyncResolve(component) {
return function({ resolve }) {
return resolve({
@@ -290,6 +292,11 @@ const routes = [
async: asyncResolve(TagListPage),
beforeEnter: [checkLogin]
},
{
path: '/template/list',
async: asyncResolve(TemplateListPage),
beforeEnter: [checkLogin]
},
{
path: '(.*)',
redirect: '/'
+4
View File
@@ -5,6 +5,7 @@ import { useUserStore } from './user.js';
import { useAccountsStore } from './account.js';
import { useTransactionCategoriesStore } from './transactionCategory.js';
import { useTransactionTagsStore } from './transactionTag.js';
import { useTransactionTemplatesStore } from './transactionTemplate.js';
import { useTransactionsStore } from './transaction.js';
import { useOverviewStore } from './overview.js';
import { useStatisticsStore } from './statistics.js';
@@ -38,6 +39,9 @@ export const useRootStore = defineStore('root', {
const transactionCategoriesStore = useTransactionCategoriesStore();
transactionCategoriesStore.resetTransactionCategories();
const transactionTemplatesStore = useTransactionTemplatesStore();
transactionTemplatesStore.resetTransactionTemplates();
const accountsStore = useAccountsStore();
accountsStore.resetAccounts();
+477
View File
@@ -0,0 +1,477 @@
import { defineStore } from 'pinia';
import transactionConstants from '@/consts/transaction.js';
import { isDefined, isObject, isArray, isEquals } from '@/lib/common.js';
import services from '@/lib/services.js';
import logger from '@/lib/logger.js';
function loadTransactionTemplateList(state, templateType, templates) {
state.allTransactionTemplates[templateType] = templates;
state.allTransactionTemplatesMap[templateType] = {};
for (let i = 0; i < templates.length; i++) {
const template = templates[i];
state.allTransactionTemplatesMap[templateType][template.id] = template;
}
}
function addTemplateToTransactionTemplateList(state, templateType, template) {
if (isArray(state.allTransactionTemplates[templateType])) {
state.allTransactionTemplates[templateType].push(template);
}
if (isObject(state.allTransactionTemplatesMap[templateType])) {
state.allTransactionTemplatesMap[templateType][template.id] = template;
}
}
function updateTemplateInTransactionTemplateList(state, templateType, template) {
if (isArray(state.allTransactionTemplates[templateType])) {
for (let i = 0; i < state.allTransactionTemplates[templateType].length; i++) {
if (state.allTransactionTemplates[templateType][i].id === template.id) {
state.allTransactionTemplates[templateType].splice(i, 1, template);
break;
}
}
}
if (isObject(state.allTransactionTemplatesMap[templateType])) {
state.allTransactionTemplatesMap[templateType][template.id] = template;
}
}
function updateTemplateDisplayOrderInTransactionTemplateList(state, templateType, { from, to }) {
if (isArray(state.allTransactionTemplates[templateType])) {
state.allTransactionTemplates[templateType].splice(to, 0, state.allTransactionTemplates[templateType].splice(from, 1)[0]);
}
}
function updateTemplateVisibilityInTransactionTemplateList(state, templateType, { template, hidden }) {
if (isObject(state.allTransactionTemplatesMap[templateType])) {
if (state.allTransactionTemplatesMap[templateType][template.id]) {
state.allTransactionTemplatesMap[templateType][template.id].hidden = hidden;
}
}
}
function removeTemplateFromTransactionTemplateList(state, templateType, template) {
if (isArray(state.allTransactionTemplates[templateType])) {
for (let i = 0; i < state.allTransactionTemplates[templateType].length; i++) {
if (state.allTransactionTemplates[templateType][i].id === template.id) {
state.allTransactionTemplates[templateType].splice(i, 1);
break;
}
}
}
if (isObject(state.allTransactionTemplatesMap[templateType])) {
if (state.allTransactionTemplatesMap[templateType][template.id]) {
delete state.allTransactionTemplatesMap[templateType][template.id];
}
}
}
export const useTransactionTemplatesStore = defineStore('transactionTemplates', {
state: () => ({
allTransactionTemplates: {},
allTransactionTemplatesMap: {},
transactionTemplateListStatesInvalid: {},
}),
getters: {
allVisibleTemplates(state) {
const allVisibleTemplates = {};
for (const templateType in state.allTransactionTemplates) {
if (!Object.prototype.hasOwnProperty.call(state.allTransactionTemplates, templateType)) {
continue;
}
const visibleTemplates = [];
for (let i = 0; i < state.allTransactionTemplates[templateType].length; i++) {
const template = state.allTransactionTemplates[templateType][i];
if (!template.hidden) {
visibleTemplates.push(template);
}
}
allVisibleTemplates[templateType] = visibleTemplates;
}
return allVisibleTemplates;
},
allAvailableTemplatesCount(state) {
const allAvailableTemplateCounts = {};
for (const templateType in state.allTransactionTemplates) {
if (!Object.prototype.hasOwnProperty.call(state.allTransactionTemplates, templateType)) {
continue;
}
allAvailableTemplateCounts[templateType] = state.allTransactionTemplates[templateType].length;
}
return allAvailableTemplateCounts;
},
allVisibleTemplatesCount(state) {
const allVisibleTemplateCounts = {};
for (const templateType in state.allVisibleTemplates) {
if (!Object.prototype.hasOwnProperty.call(state.allVisibleTemplates, templateType)) {
continue;
}
allVisibleTemplateCounts[templateType] = state.allVisibleTemplates[templateType].length;
}
return allVisibleTemplateCounts;
}
},
actions: {
generateNewTransactionTemplateModel(templateType) {
return {
id: '',
templateType: templateType,
name: ''
};
},
updateTransactionTemplateListInvalidState(templateType, invalidState) {
this.transactionTemplateListStatesInvalid[templateType] = invalidState;
},
resetTransactionTemplates() {
this.allTransactionTemplates = {};
this.allTransactionTemplatesMap = {};
this.transactionTemplateListStatesInvalid = {};
},
loadAllTemplates({ templateType, force }) {
const self = this;
if (!force && isDefined(self.transactionTemplateListStatesInvalid[templateType]) && !self.transactionTemplateListStatesInvalid[templateType]) {
return new Promise((resolve) => {
resolve(self.allTransactionTemplates[templateType] || []);
});
}
return new Promise((resolve, reject) => {
services.getAllTransactionTemplates({ templateType }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to retrieve template list' });
return;
}
if (!isDefined(self.transactionTemplateListStatesInvalid[templateType]) || self.transactionTemplateListStatesInvalid[templateType]) {
self.updateTransactionTemplateListInvalidState(templateType, false);
}
if (force && data.result && isEquals(self.allTransactionTemplates[templateType], data.result)) {
reject({ message: 'Template list is up to date' });
return;
}
loadTransactionTemplateList(self, templateType, data.result);
resolve(data.result);
}).catch(error => {
if (force) {
logger.error('failed to force load template list', error);
} else {
logger.error('failed to load template list', error);
}
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to retrieve template list' });
} else {
reject(error);
}
});
});
},
getTemplate({ templateId }) {
return new Promise((resolve, reject) => {
services.getTransactionTemplate({
id: templateId
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to retrieve template' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to load template info', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to retrieve template' });
} else {
reject(error);
}
});
});
},
saveTemplateName({ template }) {
const self = this;
return new Promise((resolve, reject) => {
let promise = null;
if (!template.id) {
promise = services.addTransactionTemplate(template);
} else {
promise = services.modifyTransactionNameTemplate(template);
}
promise.then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
if (!template.id) {
reject({ message: 'Unable to add template' });
} else {
reject({ message: 'Unable to save template' });
}
return;
}
if (!template.id) {
addTemplateToTransactionTemplateList(self, template.templateType, data.result);
} else {
updateTemplateInTransactionTemplateList(self, template.templateType, data.result);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to save template', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
if (!template.id) {
reject({ message: 'Unable to add template' });
} else {
reject({ message: 'Unable to save template' });
}
} else {
reject(error);
}
});
});
},
modifyTemplateContent({ template }) {
const self = this;
const submitTemplate = {
id: template.id,
type: template.type,
sourceAccountId: template.sourceAccountId,
sourceAmount: template.sourceAmount,
destinationAccountId: '0',
destinationAmount: 0,
tagIds: template.tagIds,
comment: template.comment
};
if (template.type === transactionConstants.allTransactionTypes.Expense) {
submitTemplate.categoryId = template.expenseCategory;
} else if (template.type === transactionConstants.allTransactionTypes.Income) {
submitTemplate.categoryId = template.incomeCategory;
} else if (template.type === transactionConstants.allTransactionTypes.Transfer) {
submitTemplate.categoryId = template.transferCategory;
submitTemplate.destinationAccountId = template.destinationAccountId;
submitTemplate.destinationAmount = template.destinationAmount;
} else {
return Promise.reject('An error occurred');
}
return new Promise((resolve, reject) => {
services.modifyTransactionTemplate(submitTemplate).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to save template' });
return;
}
updateTemplateInTransactionTemplateList(self, template.templateType, data.result);
resolve(data.result);
}).catch(error => {
logger.error('failed to save template', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to save template' });
} else {
reject(error);
}
});
});
},
changeTemplateDisplayOrder({ templateType, templateId, from, to }) {
const self = this;
return new Promise((resolve, reject) => {
let template = null;
if (!isArray(self.allTransactionTemplates[templateType])) {
reject({ message: 'Unable to move template' });
return;
}
for (let i = 0; i < self.allTransactionTemplates[templateType].length; i++) {
if (self.allTransactionTemplates[templateType][i].id === templateId) {
template = self.allTransactionTemplates[templateType][i];
break;
}
}
if (!template || !self.allTransactionTemplates[templateType][to]) {
reject({ message: 'Unable to move template' });
return;
}
if (isDefined(self.transactionTemplateListStatesInvalid[templateType]) && !self.transactionTemplateListStatesInvalid[templateType]) {
self.updateTransactionTemplateListInvalidState(templateType, true);
}
updateTemplateDisplayOrderInTransactionTemplateList(self, templateType, {
template: template,
from: from,
to: to
});
resolve();
});
},
updateTemplateDisplayOrders({ templateType }) {
const self = this;
const newDisplayOrders = [];
if (isArray(self.allTransactionTemplates[templateType])) {
for (let i = 0; i < self.allTransactionTemplates[templateType].length; i++) {
newDisplayOrders.push({
id: self.allTransactionTemplates[templateType][i].id,
displayOrder: i + 1
});
}
}
return new Promise((resolve, reject) => {
services.moveTransactionTemplate({
newDisplayOrders: newDisplayOrders
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to move template' });
return;
}
if (!isDefined(self.transactionTemplateListStatesInvalid[templateType]) || self.transactionTemplateListStatesInvalid[templateType]) {
self.updateTransactionTemplateListInvalidState(templateType, false);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to save templates display order', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to move template' });
} else {
reject(error);
}
});
});
},
hideTemplate({ template, hidden }) {
const self = this;
return new Promise((resolve, reject) => {
services.hideTransactionTemplate({
id: template.id,
hidden: hidden
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
if (hidden) {
reject({ message: 'Unable to hide this template' });
} else {
reject({ message: 'Unable to unhide this template' });
}
return;
}
updateTemplateVisibilityInTransactionTemplateList(self, template.templateType, {
template: template,
hidden: hidden
});
resolve(data.result);
}).catch(error => {
logger.error('failed to change template visibility', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
if (hidden) {
reject({ message: 'Unable to hide this template' });
} else {
reject({ message: 'Unable to unhide this template' });
}
} else {
reject(error);
}
});
});
},
deleteTemplate({ template, beforeResolve }) {
const self = this;
return new Promise((resolve, reject) => {
services.deleteTransactionTemplate({
id: template.id
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to delete this template' });
return;
}
if (beforeResolve) {
beforeResolve(() => {
removeTemplateFromTransactionTemplateList(self, template.templateType, template);
});
} else {
removeTemplateFromTransactionTemplateList(self, template.templateType, template);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to delete template', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to delete this template' });
} else {
reject(error);
}
});
});
}
}
});
+8
View File
@@ -61,6 +61,12 @@
<span class="nav-item-title">{{ $t('Transaction Tags') }}</span>
</router-link>
</li>
<li class="nav-link">
<router-link to="/template/list">
<v-icon class="nav-item-icon" :icon="icons.templates"/>
<span class="nav-item-title">{{ $t('Transaction Templates') }}</span>
</router-link>
</li>
<li class="nav-section-title">
<div class="title-wrapper">
<span class="title-text">{{ $t('Miscellaneous') }}</span>
@@ -196,6 +202,7 @@ import {
mdiCreditCardOutline,
mdiViewDashboardOutline,
mdiTagOutline,
mdiClipboardTextOutline,
mdiChartPieOutline,
mdiSwapHorizontal,
mdiCogOutline,
@@ -225,6 +232,7 @@ export default {
accounts: mdiCreditCardOutline,
categories: mdiViewDashboardOutline,
tags: mdiTagOutline,
templates: mdiClipboardTextOutline,
statistics: mdiChartPieOutline,
exchangeRates: mdiSwapHorizontal,
settings: mdiCogOutline,
+585
View File
@@ -0,0 +1,585 @@
<template>
<v-row class="match-height">
<v-col cols="12">
<v-card>
<template #title>
<div class="title-and-toolbar d-flex align-center">
<span>{{ $t('Transaction Templates') }}</span>
<v-btn class="ml-3" color="default" variant="outlined"
:disabled="loading || updating || hasEditingTemplateName" @click="add">{{ $t('Add') }}</v-btn>
<v-btn class="ml-3" color="primary" variant="tonal"
:disabled="loading || updating || hasEditingTemplateName" @click="saveSortResult"
v-if="displayOrderModified">{{ $t('Save Display Order') }}</v-btn>
<v-btn density="compact" color="default" variant="text" size="24"
class="ml-2" :icon="true" :disabled="loading || updating || hasEditingTemplateName"
:loading="loading" @click="reload">
<template #loader>
<v-progress-circular indeterminate size="20"/>
</template>
<v-icon :icon="icons.refresh" size="24" />
<v-tooltip activator="parent">{{ $t('Refresh') }}</v-tooltip>
</v-btn>
<v-spacer/>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:disabled="loading || updating || hasEditingTemplateName" :icon="true">
<v-icon :icon="icons.more" />
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="icons.show"
:title="$t('Show Hidden Templates')"
v-if="!showHidden" @click="showHidden = true"></v-list-item>
<v-list-item :prepend-icon="icons.hide"
:title="$t('Hide Hidden Templates')"
v-if="showHidden" @click="showHidden = false"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<v-table class="transaction-templates-table table-striped" :hover="!loading">
<thead>
<tr>
<th>
<div class="d-flex align-center">
<span>{{ $t('Template Name') }}</span>
<v-spacer/>
<span>{{ $t('Operation') }}</span>
</div>
</th>
</tr>
</thead>
<tbody v-if="loading && noAvailableTemplate && !newTemplate">
<tr :key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]">
<td class="px-0">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
</tbody>
<tbody v-if="!loading && noAvailableTemplate && !newTemplate">
<tr>
<td>{{ $t('No available template') }}</td>
</tr>
</tbody>
<draggable-list tag="tbody"
item-key="id"
handle=".drag-handle"
ghost-class="dragging-item"
:class="{ 'has-bottom-border': newTemplate }"
:disabled="noAvailableTemplate"
v-model="templates"
@change="onMove">
<template #item="{ element }">
<tr class="transaction-templates-table-row text-sm" v-if="showHidden || !element.hidden">
<td>
<div class="d-flex align-center">
<div class="d-flex align-center" v-if="editingTemplateName.id !== element.id">
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="icons.hide"
v-if="element.hidden">
<v-icon size="20" start :icon="icons.text"/>
</v-badge>
<v-icon size="20" start :icon="icons.text" v-else-if="!element.hidden"/>
<span class="transaction-template-name">{{ element.name }}</span>
</div>
<v-text-field class="w-100 mr-2" type="text"
density="compact" variant="underlined"
:disabled="loading || updating"
:placeholder="$t('Template Name')"
v-model="editingTemplateName.name"
v-else-if="editingTemplateName.id === element.id"
@keyup.enter="saveName(editingTemplateName)"
>
<template #prepend>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="icons.hide"
v-if="element.hidden">
<v-icon size="20" start :icon="icons.text"/>
</v-badge>
<v-icon size="20" start :icon="icons.text" v-else-if="!element.hidden"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2 ml-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="element.hidden ? icons.show : icons.hide"
:loading="templateHiding[element.id]"
:disabled="loading || updating"
v-if="editingTemplateName.id !== element.id"
@click="hide(element, !element.hidden)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ element.hidden ? $t('Show') : $t('Hide') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="icons.edit"
:loading="templateNameUpdating[element.id]"
:disabled="loading || updating"
v-if="editingTemplateName.id !== element.id"
@click="modifyName(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ $t('Modify Name') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="icons.edit"
:loading="templateNameUpdating[element.id]"
:disabled="loading || updating"
v-if="editingTemplateName.id !== element.id"
@click="edit(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ $t('Edit') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="icons.remove"
:loading="templateRemoving[element.id]"
:disabled="loading || updating"
v-if="editingTemplateName.id !== element.id"
@click="remove(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ $t('Delete') }}
</v-btn>
<v-btn class="px-2"
density="comfortable" variant="text"
:prepend-icon="icons.confirm"
:loading="templateNameUpdating[element.id]"
:disabled="loading || updating || !isTemplateModified(element)"
v-if="editingTemplateName.id === element.id" @click="saveName(editingTemplateName)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ $t('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="icons.cancel"
:disabled="loading || updating"
v-if="editingTemplateName.id === element.id" @click="cancelSaveName(editingTemplateName)">
{{ $t('Cancel') }}
</v-btn>
<span class="ml-2">
<v-icon :class="!loading && !updating && availableTemplateCount > 1 ? 'drag-handle' : 'disabled'"
:icon="icons.drag"/>
<v-tooltip activator="parent" v-if="!loading && !updating && availableTemplateCount > 1">{{ $t('Drag to Reorder') }}</v-tooltip>
</span>
</div>
</td>
</tr>
</template>
</draggable-list>
<tbody v-if="newTemplate">
<tr class="text-sm" :class="{ 'even-row': availableTemplateCount & 1 === 1}">
<td>
<div class="d-flex align-center">
<v-text-field class="w-100 mr-2" type="text" color="primary"
density="compact" variant="underlined"
:disabled="loading || updating" :placeholder="$t('Template Name')"
v-model="newTemplate.name" @keyup.enter="saveName(newTemplate)">
<template #prepend>
<v-icon size="20" start :icon="icons.text"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2" density="comfortable" variant="text"
:prepend-icon="icons.confirm"
:loading="templateNameUpdating[null]"
:disabled="loading || updating || !isTemplateModified(newTemplate)"
@click="saveName(newTemplate)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ $t('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="icons.cancel"
:disabled="loading || updating"
@click="cancelSaveName(newTemplate)">
{{ $t('Cancel') }}
</v-btn>
<span class="ml-2">
<v-icon class="disabled" :icon="icons.drag"/>
</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-col>
</v-row>
<edit-dialog ref="editDialog" :persistent="true" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script>
import EditDialog from '@/views/desktop/transactions/list/dialogs/EditDialog.vue';
import { mapStores } from 'pinia';
import { useTransactionTemplatesStore } from '@/stores/transactionTemplate.js';
import templateConstants from '@/consts/template.js';
import { generateRandomUUID } from '@/lib/misc.js';
import {
mdiRefresh,
mdiPlus,
mdiPencilOutline,
mdiCheck,
mdiClose,
mdiEyeOffOutline,
mdiEyeOutline,
mdiDeleteOutline,
mdiDrag,
mdiDotsVertical,
mdiTextBoxOutline
} from '@mdi/js';
export default {
components: {
EditDialog
},
data() {
return {
templateType: templateConstants.allTemplateTypes.Normal,
newTemplate: null,
editingTemplateName: {},
loading: true,
updating: false,
templateNameUpdating: {},
templateHiding: {},
templateRemoving: {},
displayOrderModified: false,
showHidden: false,
icons: {
refresh: mdiRefresh,
add: mdiPlus,
edit: mdiPencilOutline,
confirm: mdiCheck,
cancel: mdiClose,
show: mdiEyeOutline,
hide: mdiEyeOffOutline,
remove: mdiDeleteOutline,
drag: mdiDrag,
more: mdiDotsVertical,
text: mdiTextBoxOutline
}
};
},
computed: {
...mapStores(useTransactionTemplatesStore),
templates() {
return this.transactionTemplatesStore.allTransactionTemplates[this.templateType] || [];
},
noAvailableTemplate() {
for (let i = 0; i < this.templates.length; i++) {
if (this.showHidden || !this.templates[i].hidden) {
return false;
}
}
return true;
},
availableTemplateCount() {
let count = 0;
for (let i = 0; i < this.templates.length; i++) {
if (this.showHidden || !this.templates[i].hidden) {
count++;
}
}
return count;
},
hasEditingTemplateName() {
return !!(this.newTemplate || (this.editingTemplateName && this.editingTemplateName.id && this.editingTemplateName.id !== ''));
}
},
created() {
const self = this;
self.loading = true;
self.transactionTemplatesStore.loadAllTemplates({
templateType: self.templateType,
force: false
}).then(() => {
self.loading = false;
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
methods: {
reload() {
if (this.hasEditingTemplateName) {
return;
}
const self = this;
self.loading = true;
self.transactionTemplatesStore.loadAllTemplates({
templateType: self.templateType,
force: true
}).then(() => {
self.loading = false;
self.displayOrderModified = false;
self.$refs.snackbar.showMessage('Template list has been updated');
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
onMove(event) {
if (!event || !event.moved) {
return;
}
const self = this;
const moveEvent = event.moved;
if (!moveEvent.element || !moveEvent.element.id) {
self.$refs.snackbar.showMessage('Unable to move template');
return;
}
self.transactionTemplatesStore.changeTemplateDisplayOrder({
templateType: self.templateType,
templateId: moveEvent.element.id,
from: moveEvent.oldIndex,
to: moveEvent.newIndex
}).then(() => {
self.displayOrderModified = true;
}).catch(error => {
self.$refs.snackbar.showError(error);
});
},
saveSortResult() {
const self = this;
if (!self.displayOrderModified) {
return;
}
self.loading = true;
self.transactionTemplatesStore.updateTemplateDisplayOrders({
templateType: self.templateType
}).then(() => {
self.loading = false;
self.displayOrderModified = false;
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
add() {
this.newTemplate = {
templateType: this.templateType,
name: '',
clientSessionId: generateRandomUUID()
};
},
modifyName(template) {
this.editingTemplateName.id = template.id;
this.editingTemplateName.templateType = template.templateType;
this.editingTemplateName.name = template.name;
},
saveName(template) {
const self = this;
self.updating = true;
self.templateNameUpdating[template.id || null] = true;
self.transactionTemplatesStore.saveTemplateName({
template: template
}).then(() => {
self.updating = false;
self.templateNameUpdating[template.id || null] = false;
if (template.id) {
self.editingTemplateName = {};
} else {
self.newTemplate = null;
}
}).catch(error => {
self.updating = false;
self.templateNameUpdating[template.id || null] = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
cancelSaveName(template) {
if (template.id) {
this.editingTemplateName = {};
} else {
this.newTemplate = null;
}
},
edit(template) {
const self = this;
self.$refs.editDialog.open({
editTemplateId: template.id,
currentTemplate: {
type: template.type,
categoryId: template.categoryId,
sourceAccountId: template.sourceAccountId,
destinationAccountId: template.destinationAccountId,
sourceAmount: template.sourceAmount,
destinationAmount: template.destinationAmount,
tagIds: template.tagIds,
comment: template.comment
}
}).then(result => {
if (result && result.message) {
self.$refs.snackbar.showMessage(result.message);
}
self.reload(false);
}).catch(error => {
if (error) {
self.$refs.snackbar.showError(error);
}
});
},
isTemplateModified(template) {
if (template.id) {
return this.editingTemplateName && this.editingTemplateName.name !== '' && this.editingTemplateName.name !== template.name;
} else {
return template.name !== '';
}
},
hide(template, hidden) {
const self = this;
self.updating = true;
self.templateHiding[template.id] = true;
self.transactionTemplatesStore.hideTemplate({
template: template,
hidden: hidden
}).then(() => {
self.updating = false;
self.templateHiding[template.id] = false;
}).catch(error => {
self.updating = false;
self.templateHiding[template.id] = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
remove(template) {
const self = this;
self.$refs.confirmDialog.open('Are you sure you want to delete this template?').then(() => {
self.updating = true;
self.templateRemoving[template.id] = true;
self.transactionTemplatesStore.deleteTemplate({
template: template
}).then(() => {
self.updating = false;
self.templateRemoving[template.id] = false;
}).catch(error => {
self.updating = false;
self.templateRemoving[template.id] = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
}
}
}
</script>
<style>
.transaction-templates-table tr.transaction-templates-table-row .hover-display {
display: none;
}
.transaction-templates-table tr.transaction-templates-table-row:hover .hover-display {
display: inline-grid;
}
.transaction-templates-table tr:not(:last-child) > td > div {
padding-bottom: 1px;
}
.transaction-templates-table .has-bottom-border tr:last-child > td > div {
padding-bottom: 1px;
}
.transaction-templates-table tr.transaction-templates-table-row .right-bottom-icon .v-badge__badge {
padding-bottom: 1px;
}
.transaction-templates-table .v-text-field .v-input__prepend {
margin-right: 0;
color: rgba(var(--v-theme-on-surface));
}
.transaction-templates-table .v-text-field .v-input__prepend .v-badge > .v-badge__wrapper > .v-icon {
opacity: var(--v-medium-emphasis-opacity);
}
.transaction-templates-table .v-text-field.v-input--plain-underlined .v-input__prepend {
padding-top: 10px;
}
.transaction-templates-table .v-text-field .v-field__input {
font-size: 0.875rem;
padding-top: 0;
color: rgba(var(--v-theme-on-surface));
}
.transaction-templates-table .transaction-template-name {
font-size: 0.875rem;
}
.transaction-templates-table tr .v-text-field .v-field__input {
padding-bottom: 1px;
}
</style>
@@ -8,7 +8,7 @@
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</div>
<v-btn density="comfortable" color="default" variant="text" class="ml-2" :icon="true"
:disabled="loading || submitting" v-if="mode !== 'view'">
:disabled="loading || submitting" v-if="mode !== 'view' && (mode !== 'editTemplate' || transaction.type === allTransactionTypes.Transfer)">
<v-icon :icon="icons.more" />
<v-menu activator="parent">
<v-list>
@@ -24,13 +24,13 @@
:title="$t('Swap Account and Amount')"
v-if="transaction.type === allTransactionTypes.Transfer"
@click="swapTransactionData(true, true)"></v-list-item>
<v-divider v-if="transaction.type === allTransactionTypes.Transfer" />
<v-divider v-if="mode !== 'editTemplate' && transaction.type === allTransactionTypes.Transfer" />
<v-list-item :prepend-icon="icons.show"
:title="$t('Show Amount')"
v-if="transaction.hideAmount" @click="transaction.hideAmount = false"></v-list-item>
v-if="mode !== 'editTemplate' && transaction.hideAmount" @click="transaction.hideAmount = false"></v-list-item>
<v-list-item :prepend-icon="icons.hide"
:title="$t('Hide Amount')"
v-if="!transaction.hideAmount" @click="transaction.hideAmount = true"></v-list-item>
v-if="mode !== 'editTemplate' && !transaction.hideAmount" @click="transaction.hideAmount = true"></v-list-item>
</v-list>
</v-menu>
</v-btn>
@@ -38,18 +38,18 @@
</template>
<v-card-text class="d-flex flex-column flex-md-row mt-md-4 pt-0">
<div class="mb-4">
<v-tabs class="v-tabs-pill" direction="vertical" :class="{ 'readonly': mode !== 'add' }"
<v-tabs class="v-tabs-pill" direction="vertical" :class="{ 'readonly': mode !== 'add' && mode !== 'editTemplate' }"
:disabled="loading || submitting" v-model="transaction.type">
<v-tab :value="allTransactionTypes.Expense" :disabled="mode !== 'add' && transaction.type !== allTransactionTypes.Expense" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<v-tab :value="allTransactionTypes.Expense" :disabled="mode !== 'add' && mode !== 'editTemplate' && transaction.type !== allTransactionTypes.Expense" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<span>{{ $t('Expense') }}</span>
</v-tab>
<v-tab :value="allTransactionTypes.Income" :disabled="mode !== 'add' && transaction.type !== allTransactionTypes.Income" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<v-tab :value="allTransactionTypes.Income" :disabled="mode !== 'add' && mode !== 'editTemplate' && transaction.type !== allTransactionTypes.Income" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<span>{{ $t('Income') }}</span>
</v-tab>
<v-tab :value="allTransactionTypes.Transfer" :disabled="mode !== 'add' && transaction.type !== allTransactionTypes.Transfer" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<v-tab :value="allTransactionTypes.Transfer" :disabled="mode !== 'add' && mode !== 'editTemplate' && transaction.type !== allTransactionTypes.Transfer" v-if="transaction.type !== allTransactionTypes.ModifyBalance">
<span>{{ $t('Transfer') }}</span>
</v-tab>
<v-tab :value="allTransactionTypes.ModifyBalance" v-if="transaction.type === allTransactionTypes.ModifyBalance">
<v-tab :value="allTransactionTypes.ModifyBalance" v-if="mode !== 'editTemplate' && transaction.type === allTransactionTypes.ModifyBalance">
<span>{{ $t('Modify Balance') }}</span>
</v-tab>
</v-tabs>
@@ -58,7 +58,7 @@
<v-tab value="basicInfo">
<span>{{ $t('Basic Information') }}</span>
</v-tab>
<v-tab value="map" :disabled="!transaction.geoLocation" v-if="mapProvider">
<v-tab value="map" :disabled="!transaction.geoLocation" v-if="mode !== 'editTemplate' && mapProvider">
<span>{{ $t('Location on Map') }}</span>
</v-tab>
</v-tabs>
@@ -177,7 +177,7 @@
v-model="transaction.destinationAccountId">
</two-column-select>
</v-col>
<v-col cols="12" md="6">
<v-col cols="12" md="6" v-if="mode !== 'editTemplate'">
<date-time-select
:readonly="mode === 'view'"
:disabled="loading || submitting"
@@ -185,7 +185,7 @@
v-model="transaction.time"
@error="showDateTimeError" />
</v-col>
<v-col cols="12" md="6">
<v-col cols="12" md="6" v-if="mode !== 'editTemplate'">
<v-autocomplete
class="transaction-edit-timezone"
item-title="displayNameWithUtcOffset"
@@ -207,7 +207,7 @@
</template>
</v-autocomplete>
</v-col>
<v-col cols="12" md="12">
<v-col cols="12" md="12" v-if="mode !== 'editTemplate'">
<v-select
persistent-placeholder
:readonly="mode === 'view'"
@@ -334,6 +334,7 @@ import { useAccountsStore } from '@/stores/account.js';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import { useTransactionTagsStore } from '@/stores/transactionTag.js';
import { useTransactionsStore } from '@/stores/transaction.js';
import { useTransactionTemplatesStore } from '@/stores/transactionTemplate.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import categoryConstants from '@/consts/category.js';
@@ -401,12 +402,14 @@ export default {
};
},
computed: {
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useTransactionTagsStore, useTransactionsStore, useExchangeRatesStore),
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useTransactionTagsStore, useTransactionsStore, useTransactionTemplatesStore, useExchangeRatesStore),
title() {
if (this.mode === 'add') {
return 'Add Transaction';
} else if (this.mode === 'edit') {
return 'Edit Transaction';
} else if (this.mode === 'editTemplate') {
return 'Edit Transaction Template';
} else {
return 'Transaction Detail';
}
@@ -590,14 +593,14 @@ export default {
}
},
'transaction.sourceAmount': function (newValue, oldValue) {
if (this.mode === 'view') {
if (this.mode === 'view' || this.loading) {
return;
}
this.transactionsStore.setTransactionSuitableDestinationAmount(this.transaction, oldValue, newValue);
},
'transaction.destinationAmount': function (newValue) {
if (this.mode === 'view') {
if (this.mode === 'view' || this.loading) {
return;
}
@@ -642,6 +645,15 @@ export default {
self.editTransactionId = options.id;
promises.push(self.transactionsStore.getTransaction({ transactionId: self.editTransactionId }));
} else if (options && options.editTemplateId) {
if (options.currentTemplate) {
self.setTransaction(options.currentTemplate, options, false, false);
}
self.mode = 'editTemplate';
self.transaction.id = options.editTemplateId;
promises.push(self.transactionTemplatesStore.getTemplate({ templateId: options.editTemplateId }));
} else {
self.mode = 'add';
self.editTransactionId = null;
@@ -671,10 +683,13 @@ export default {
return;
}
if (options.id && responses[3]) {
if (options && options.id && responses[3]) {
const transaction = responses[3];
self.setTransaction(transaction, options, true, true);
self.originalTransactionEditable = transaction.editable;
} else if (options && options.editTemplateId && responses[3]) {
const template = responses[3];
self.setTransaction(template, options, false, false);
} else {
self.setTransaction(null, options, true, true);
}
@@ -701,31 +716,59 @@ export default {
save() {
const self = this;
if (self.mode === 'view') {
return;
}
if (self.mode === 'add' || self.mode === 'edit') {
const doSubmit = function () {
self.submitting = true;
const doSubmit = function () {
self.transactionsStore.saveTransaction({
transaction: self.transaction,
defaultCurrency: self.defaultCurrency,
isEdit: self.mode === 'edit',
clientSessionId: self.clientSessionId
}).then(() => {
self.submitting = false;
if (self.resolve) {
if (self.mode === 'add') {
self.resolve({
message: 'You have added a new transaction'
});
} else if (self.mode === 'edit') {
self.resolve({
message: 'You have saved this transaction'
});
}
}
self.showState = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
};
if (self.transaction.sourceAmount === 0) {
self.$refs.confirmDialog.open('Are you sure you want to save this transaction with a zero amount?').then(() => {
doSubmit();
});
} else {
doSubmit();
}
} else if (self.mode === 'editTemplate') {
self.submitting = true;
self.transactionsStore.saveTransaction({
transaction: self.transaction,
defaultCurrency: self.defaultCurrency,
isEdit: self.mode === 'edit',
clientSessionId: self.clientSessionId
self.transactionTemplatesStore.modifyTemplateContent({
template: self.transaction
}).then(() => {
self.submitting = false;
if (self.resolve) {
if (self.mode === 'add') {
self.resolve({
message: 'You have added a new transaction'
});
} else if (self.mode === 'edit') {
self.resolve({
message: 'You have saved this transaction'
});
}
self.resolve({
message: 'You have saved this template'
});
}
self.showState = false;
@@ -736,17 +779,13 @@ export default {
self.$refs.snackbar.showError(error);
}
});
};
if (self.transaction.sourceAmount === 0) {
self.$refs.confirmDialog.open('Are you sure you want to save this transaction with a zero amount?').then(() => {
doSubmit();
});
} else {
doSubmit();
}
},
duplicate() {
if (this.mode !== 'view') {
return;
}
this.editTransactionId = null;
this.transaction.id = null;
this.transaction.time = getCurrentUnixTime();
@@ -756,11 +795,19 @@ export default {
this.mode = 'add';
},
edit() {
if (this.mode !== 'view') {
return;
}
this.mode = 'edit';
},
remove() {
const self = this;
if (self.mode !== 'view') {
return;
}
self.$refs.confirmDialog.open('Are you sure you want to delete this transaction?').then(() => {
self.submitting = true;
@@ -873,7 +920,11 @@ export default {
type: options.type,
categoryId: options.categoryId,
accountId: options.accountId,
tagIds: options.tagIds
destinationAccountId: options.destinationAccountId,
amount: options.amount,
destinationAmount: options.destinationAmount,
tagIds: options.tagIds,
comment: options.comment
},
setContextData,
convertContextTime
+16
View File
@@ -0,0 +1,16 @@
<template>
<f7-page>
</f7-page>
</template>
<script>
export default {
props: [
'f7router'
],
}
</script>
<style>
</style>