优化账户余额调整功能:新增调整余额的逻辑,更新相关页面以显示账户余额和可用额度,调整路由配置以移除不必要的动画效果。

This commit is contained in:
2026-04-05 18:40:52 +08:00
parent 5fbff39c4f
commit c7c84c74d3
9 changed files with 255 additions and 84 deletions
+48 -45
View File
@@ -1,7 +1,7 @@
# ezBookkeeping 个人需求清单 # ezBookkeeping 个人需求清单
> 基于 fork 版本的定制开发需求,持续更新。 > 基于 fork 版本的定制开发需求,持续更新。
> 标注:✅ 易实现 | ⚠️ 中等难度 | ❌ 难/暂缓 | ❓ 待确认 | 🟢 已完成 > 标注:⚠️ 中等难度 | ❌ 难/暂缓 | 🟢 已完成
--- ---
@@ -22,15 +22,18 @@
## 二、账户功能 ## 二、账户功能
### 2. ⚠️ 信用卡账户:额度与剩余额度 ### 2. 🟢 信用卡账户:额度与剩余额度
**描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表和详情中展示「已用额度」和「剩余额度」。 **描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表显示「可用额度」。
**实现思路** **已完成**
- 后端:账户数据库表新增 `credit_limit` 字段,修改 API - 后端:`AccountExtend` JSON blob 新增 `CreditLimit` 字段(无需数据库迁移)
- 前端:账户编辑页增加额度输入,账户列表/详情显示剩余额度(= 额度 - 欠款) - API`AccountCreateRequest` / `AccountModifyRequest` / `AccountInfoResponse` 增加 `creditLimit`
- 涉及文件:后端 `pkg/models/account.go`、前端 `EditPage.vue``ListPage.vue` - 前端 model`Account` 类增加 `creditLimit` 字段,同步 `toCreateRequest` / `toModifyRequest`
- 移动端 EditPageCreditCard 分类时显示信用额度输入项
- 移动端 ListPage:账户列表显示「可用额度」(= `creditLimit + balance`,因信用卡余额为负值)
- 语言包:中英文均已添加 "Credit Limit" / "Available"
### 8. ⚠️ 账户交易列表顶部显示账户信息 ### 3. ⚠️ 账户交易列表顶部显示账户信息
**描述:** 在按账户筛选的交易列表页面顶部,显示账户名称、余额(或信用卡欠款+额度)等信息卡片。 **描述:** 在按账户筛选的交易列表页面顶部,显示账户名称、余额(或信用卡欠款+额度)等信息卡片。
**实现思路:** **实现思路:**
@@ -39,28 +42,31 @@
- 若多账户筛选则显示多张卡片 - 若多账户筛选则显示多张卡片
- 涉及文件:`src/views/mobile/transactions/ListPage.vue` - 涉及文件:`src/views/mobile/transactions/ListPage.vue`
### 9. 允许直接修改账户余额(自动插入调整记录) ### 4. 🟢 允许直接修改账户余额(自动插入调整记录)
**描述:** 在账户详情提供「调整余额」入口,输入目标余额后,自动计算差值并插入一条「余额调整」类型交易记录。 **描述:** 在账户编辑页直接修改余额字段,保存时自动计算差值并插入一条「余额调整」类型交易记录。
**现状** 代码中已存在 `ModifyBalance` 交易类型,可能已有后端支持,仅需确认前端入口是否存在。 **已完成**
- 后端:移除「账户已有交易时不允许添加 ModifyBalance」限制
**实现思路:** - 后端:`Amount``RelatedAccountAmount` 均存储 delta(而非目标余额);删除该交易可撤销 delta
- 确认 `ModifyBalance` 交易类型的前端入口 - 前端 store:新增 `adjustAccountBalance({ accountId, targetBalance, currentBalance })` 函数
- 若无,在账户详情页增加「调整余额」按钮,跳转至记账页并预填该类型 - 移动端 EditPage:余额字段对已有账户解除只读,保存时若余额变化先调用 `adjustAccountBalance`;捕获 `NothingWillBeUpdated (200004)` 视为成功
- 桌面端 EditDialog:同上逻辑,支持多子账户逐一调整
--- ---
## 三、记账页面 ## 三、记账页面
### 3. 记账页显示当前账户余额 / 欠款+额度 ### 5. 🟢 记账页显示当前账户余额 / 欠款+额度
**描述:** 在记账页面选择账户后,在账户名称旁或下方显示该账户的当前余额(普通账户)或欠款+额度(信用卡)。 **描述:** 在记账页面选择账户后,在账户名称下方显示该账户的当前余额(普通账户)或欠款金额(信用卡/负债)。
**实现思路** **已完成**
- 纯前端改动,从 account store 读取余额信息 - 信用卡账户显示「可用额度: ¥xxx」(= creditLimit + balance
- 在账户选择区域旁增加余额展示 - 普通负债账户显示欠款正数,普通资产账户显示余额
- 涉及文件:`src/views/mobile/transactions/EditPage.vue` - 转账时源账户和目标账户均显示
- 移动端:账户行 footer;桌面端:`custom-selection-secondary-text`
- 涉及文件:`src/views/mobile/transactions/EditPage.vue``src/views/desktop/transactions/list/dialogs/EditDialog.vue`
### 11. 记录上次选择的账户 ### 6. 记录上次选择的账户
**描述:** 新建交易时,默认选中上次使用的账户,而非每次重置为默认账户。 **描述:** 新建交易时,默认选中上次使用的账户,而非每次重置为默认账户。
**实现思路:** **实现思路:**
@@ -72,7 +78,7 @@
## 四、小键盘 ## 四、小键盘
### 6. 🟢 小键盘布局调整 ### 7. 🟢 小键盘布局调整
**描述:** 调整数字键盘布局。 **描述:** 调整数字键盘布局。
**已完成:** **已完成:**
@@ -93,7 +99,7 @@
## 五、交易详情 ## 五、交易详情
### 7. 🟢 交易详情菜单增加「编辑」和「删除」 ### 8. 🟢 交易详情菜单增加「编辑」和「删除」
**描述:** 交易详情页三点菜单中增加编辑和删除操作。 **描述:** 交易详情页三点菜单中增加编辑和删除操作。
**已完成:** **已完成:**
@@ -106,7 +112,7 @@
## 六、分类选择 ## 六、分类选择
### 10. 分类选择默认全部展开 ### 9. 分类选择默认全部展开
**描述:** 在记账页面选择分类时,默认将所有一级分类展开显示子分类,或在设置中可配置哪些分类默认展开。 **描述:** 在记账页面选择分类时,默认将所有一级分类展开显示子分类,或在设置中可配置哪些分类默认展开。
**实现思路:** **实现思路:**
@@ -118,29 +124,26 @@
## 七、性能与动画 ## 七、性能与动画
### 12. 🟢 底部 Tab 切换无动画 + 全局动画加速 ### 10. 🟢 全局动画加速
**描述:** 底部 5 个主 Tab 切换无动画,其余动画加速。 **描述:** 全局页面跳转及各类弹层动画加速。
**已完成:** **已完成:**
- 5 个主 Tab(首页、交易列表、账户、统计、设置)路由加 `animate: false`
- 全局动画时长从 300ms 加快至 150ms(页面跳转、Sheet、ActionSheet、Popup、Dialog、Popover - 全局动画时长从 300ms 加快至 150ms(页面跳转、Sheet、ActionSheet、Popup、Dialog、Popover
- 涉及文件:`src/router/mobile.ts``src/styles/mobile/global.scss` - Tab 切换动画保持原样(设置中已有开关可控制)
- 涉及文件:`src/styles/mobile/global.scss`
### 4. 🟢 点击响应卡顿优化 ### 11. 🟢 点击响应卡顿优化
**描述:** 点击按钮有延迟感,点不上的问题。 **描述:** 点击按钮有延迟感,点不上的问题。
**已完成:** **已完成:**
- `tapHoldDelay` 从默认 750ms 降至 500ms,减少长按判定等待 - `tapHoldDelay` 从默认 750ms 降至 500ms,减少长按判定等待
- 涉及文件:`src/MobileApp.vue` - 涉及文件:`src/MobileApp.vue`
**未完成(已放弃):**
- HomePage 预加载 tags 数据(用户已 revert
--- ---
## 八、离线 / 缓存 ## 八、离线 / 缓存
### 5. ❌ 本地优先 / 离线数据缓存(暂缓) ### 12. ❌ 本地优先 / 离线数据缓存(暂缓)
**描述:** 交易数据缓存至本地,优先展示缓存数据,后台静默拉取最新数据并更新。 **描述:** 交易数据缓存至本地,优先展示缓存数据,后台静默拉取最新数据并更新。
**现状:** Service Worker 已实现静态资源缓存(图片、字体、地图瓦片),但**交易业务数据**目前不做本地缓存。 **现状:** Service Worker 已实现静态资源缓存(图片、字体、地图瓦片),但**交易业务数据**目前不做本地缓存。
@@ -159,14 +162,14 @@
| # | 需求 | 状态 | | # | 需求 | 状态 |
|---|------|------| |---|------|------|
| 1 | 自选主题色 | ⚠️ 待做 | | 1 | 自选主题色 | ⚠️ 待做 |
| 2 | 信用卡额度 | ⚠️ 待做(需改后端) | | 2 | 信用卡额度 | 🟢 已完成 |
| 3 | 记账页显示余额 | 待做 | | 3 | 账户信息卡片 | ⚠️ 待做 |
| 4 | 点击卡顿优化 | 🟢 部分完成 | | 4 | 调整余额入口 | 🟢 完成 |
| 5 | 离线缓存 | ❌ 暂缓 | | 5 | 记账页显示余额 | 🟢 已完成 |
| 6 | 小键盘布局 | 🟢 已完成 | | 6 | 记住上次账户 | ❓ 待定 |
| 7 | 详情编辑/删除 | 🟢 已完成 | | 7 | 小键盘布局 | 🟢 已完成 |
| 8 | 账户信息卡片 | ⚠️ 待做 | | 8 | 详情编辑/删除 | 🟢 已完成 |
| 9 | 调整余额入口 | | | 9 | 分类默认展开 | |
| 10 | 分类默认展开 | ✅ 待做 | | 10 | 全局动画加速 | 🟢 已完成 |
| 11 | 记住上次账户 | ✅ 待做 | | 11 | 点击卡顿优化 | 🟢 已完成 |
| 12 | Tab 无动画 + 加速 | 🟢 已完成 | | 12 | 离线缓存 | ❌ 暂缓 |
+3 -1
View File
@@ -544,7 +544,9 @@ func (t *Transaction) ToTransactionInfoResponse(tagIds []int64, editable bool) *
destinationAccountId := int64(0) destinationAccountId := int64(0)
destinationAmount := int64(0) destinationAmount := int64(0)
if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT { if t.Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
sourceAmount = t.RelatedAccountAmount // always return delta
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT {
destinationAccountId = t.RelatedAccountId destinationAccountId = t.RelatedAccountId
destinationAmount = t.RelatedAccountAmount destinationAmount = t.RelatedAccountAmount
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN { } else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN {
+2 -11
View File
@@ -954,7 +954,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
if transaction.Amount != oldTransaction.Amount { if transaction.Amount != oldTransaction.Amount {
if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transaction.RelatedAccountAmount = oldTransaction.RelatedAccountAmount + transaction.Amount - oldTransaction.Amount transaction.RelatedAccountAmount = transaction.Amount // Amount IS the new delta
updateCols = append(updateCols, "related_account_amount") updateCols = append(updateCols, "related_account_amount")
} }
@@ -2251,17 +2251,8 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas
// Verify balance modification transaction and calculate real amount // Verify balance modification transaction and calculate real amount
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
otherTransactionExists, err := sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND account_id=?", transaction.Uid, false, sourceAccount.AccountId).Limit(1).Exist(&models.Transaction{})
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to get whether other transactions exist, because %s", err.Error())
return err
} else if otherTransactionExists {
return errs.ErrBalanceModificationTransactionCannotAddWhenNotEmpty
}
transaction.RelatedAccountId = transaction.AccountId transaction.RelatedAccountId = transaction.AccountId
transaction.RelatedAccountAmount = transaction.Amount - sourceAccount.Balance transaction.RelatedAccountAmount = transaction.Amount // Amount IS the delta
} else { // Not allow to add transaction before balance modification transaction } else { // Not allow to add transaction before balance modification transaction
otherTransactionExists := false otherTransactionExists := false
-7
View File
@@ -128,9 +128,6 @@ const routes: Router.RouteParameters[] = [
path: '/', path: '/',
async: asyncResolve(HomePage), async: asyncResolve(HomePage),
beforeEnter: [checkLogin], beforeEnter: [checkLogin],
options: {
animate: false,
}
}, },
{ {
path: '/login', path: '/login',
@@ -160,7 +157,6 @@ const routes: Router.RouteParameters[] = [
path: '/transaction/list', path: '/transaction/list',
async: asyncResolve(TransactionListPage), async: asyncResolve(TransactionListPage),
beforeEnter: [checkLogin], beforeEnter: [checkLogin],
options: { animate: false }
}, },
{ {
path: '/transaction/filter/amount', path: '/transaction/filter/amount',
@@ -186,7 +182,6 @@ const routes: Router.RouteParameters[] = [
path: '/account/list', path: '/account/list',
async: asyncResolve(AccountListPage), async: asyncResolve(AccountListPage),
beforeEnter: [checkLogin], beforeEnter: [checkLogin],
options: { animate: false }
}, },
{ {
path: '/account/add', path: '/account/add',
@@ -212,7 +207,6 @@ const routes: Router.RouteParameters[] = [
path: '/statistic/transaction', path: '/statistic/transaction',
async: asyncResolve(StatisticsTransactionPage), async: asyncResolve(StatisticsTransactionPage),
beforeEnter: [checkLogin], beforeEnter: [checkLogin],
options: { animate: false }
}, },
{ {
path: '/statistic/settings', path: '/statistic/settings',
@@ -263,7 +257,6 @@ const routes: Router.RouteParameters[] = [
path: '/settings', path: '/settings',
async: asyncResolve(SettingsPage), async: asyncResolve(SettingsPage),
beforeEnter: [checkLogin], beforeEnter: [checkLogin],
options: { animate: false }
}, },
{ {
path: '/app_lock', path: '/app_lock',
+56 -1
View File
@@ -54,11 +54,12 @@ import {
splitItemsToMap, splitItemsToMap,
countSplitItems countSplitItems
} from '@/lib/common.ts'; } from '@/lib/common.ts';
import { parseDateTimeFromUnixTimeWithTimezoneOffset } from '@/lib/datetime.ts'; import { parseDateTimeFromUnixTimeWithTimezoneOffset, getCurrentUnixTime, getTimezoneOffsetMinutes } from '@/lib/datetime.ts';
import { getAmountWithDecimalNumberCount } from '@/lib/numeral.ts'; import { getAmountWithDecimalNumberCount } from '@/lib/numeral.ts';
import { getCurrencyFraction } from '@/lib/currency.ts'; import { getCurrencyFraction } from '@/lib/currency.ts';
import { getFirstVisibleCategoryId } from '@/lib/category.ts'; import { getFirstVisibleCategoryId } from '@/lib/category.ts';
import services, { type ApiResponsePromise } from '@/lib/services.ts'; import services, { type ApiResponsePromise } from '@/lib/services.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import logger from '@/lib/logger.ts'; import logger from '@/lib/logger.ts';
export interface TransactionListPartialFilter { export interface TransactionListPartialFilter {
@@ -1166,6 +1167,59 @@ export const useTransactionsStore = defineStore('transactions', () => {
}); });
} }
function adjustAccountBalance({ accountId, targetBalance, currentBalance }: { accountId: string, targetBalance: number, currentBalance: number }): Promise<boolean> {
return new Promise((resolve, reject) => {
const now = getCurrentUnixTime();
const utcOffset = getTimezoneOffsetMinutes(now);
const delta = targetBalance - currentBalance;
if (delta === 0) {
resolve(true);
return;
}
services.addTransaction({
type: TransactionType.ModifyBalance,
categoryId: '0',
time: now,
utcOffset: utcOffset,
sourceAccountId: accountId,
destinationAccountId: '0',
sourceAmount: delta,
destinationAmount: 0,
hideAmount: false,
tagIds: [],
pictureIds: [],
comment: '',
clientSessionId: generateRandomUUID()
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to adjust account balance' });
return;
}
const accountsStore = useAccountsStore();
accountsStore.loadAllAccounts({ force: true }).then(() => {
updateTransactionListInvalidState(true);
resolve(true);
}).catch(() => {
updateTransactionListInvalidState(true);
resolve(true);
});
}).catch(error => {
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to adjust account balance' });
} else {
reject(error);
}
});
});
}
function deleteTransaction({ transaction, defaultCurrency, beforeResolve }: { transaction: TransactionInfoResponse, defaultCurrency: string, beforeResolve?: BeforeResolveFunction }): Promise<boolean> { function deleteTransaction({ transaction, defaultCurrency, beforeResolve }: { transaction: TransactionInfoResponse, defaultCurrency: string, beforeResolve?: BeforeResolveFunction }): Promise<boolean> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
services.deleteTransaction({ services.deleteTransaction({
@@ -1472,6 +1526,7 @@ export const useTransactionsStore = defineStore('transactions', () => {
getReconciliationStatements, getReconciliationStatements,
getTransaction, getTransaction,
saveTransaction, saveTransaction,
adjustAccountBalance,
moveAllTransactionsBetweenAccounts, moveAllTransactionsBetweenAccounts,
deleteTransaction, deleteTransaction,
recognizeReceiptImage, recognizeReceiptImage,
@@ -131,7 +131,7 @@
</v-col> </v-col>
<v-col cols="12" :md="(!editAccountId || isNewAccount(selectedAccount)) && selectedAccount.balance ? 6 : 12" <v-col cols="12" :md="(!editAccountId || isNewAccount(selectedAccount)) && selectedAccount.balance ? 6 : 12"
v-if="account.type === AccountType.SingleAccount.type || currentAccountIndex >= 0"> v-if="account.type === AccountType.SingleAccount.type || currentAccountIndex >= 0">
<amount-input :disabled="loading || submitting || (!!editAccountId && !isNewAccount(selectedAccount))" <amount-input :disabled="loading || submitting"
:persistent-placeholder="true" :persistent-placeholder="true"
:currency="selectedAccount.currency" :currency="selectedAccount.currency"
:show-currency="true" :show-currency="true"
@@ -204,6 +204,9 @@ import { useAccountEditPageBase } from '@/views/base/accounts/AccountEditPageBas
import { useUserStore } from '@/stores/user.ts'; import { useUserStore } from '@/stores/user.ts';
import { useAccountsStore } from '@/stores/account.ts'; import { useAccountsStore } from '@/stores/account.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { KnownErrorCode } from '@/consts/api.ts';
import { itemAndIndex } from '@/core/base.ts'; import { itemAndIndex } from '@/core/base.ts';
import { AccountType } from '@/core/account.ts'; import { AccountType } from '@/core/account.ts';
@@ -254,6 +257,7 @@ const {
const userStore = useUserStore(); const userStore = useUserStore();
const accountsStore = useAccountsStore(); const accountsStore = useAccountsStore();
const transactionsStore = useTransactionsStore();
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog'); const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar'); const snackbar = useTemplateRef<SnackBarType>('snackbar');
@@ -261,6 +265,7 @@ const snackbar = useTemplateRef<SnackBarType>('snackbar');
const showState = ref<boolean>(false); const showState = ref<boolean>(false);
const activeTab = ref<string>('account'); const activeTab = ref<string>('account');
const currentAccountIndex = ref<number>(-1); const currentAccountIndex = ref<number>(-1);
const originalBalances = ref<Map<string, number>>(new Map());
const selectedAccount = computed<Account>(() => { const selectedAccount = computed<Account>(() => {
if (currentAccountIndex.value < 0) { if (currentAccountIndex.value < 0) {
@@ -310,6 +315,15 @@ function open(options?: { id?: string, currentAccount?: Account, category?: numb
accountId: editAccountId.value accountId: editAccountId.value
}).then(response => { }).then(response => {
setAccount(response); setAccount(response);
originalBalances.value.clear();
if (account.value.id) {
originalBalances.value.set(account.value.id, account.value.balance);
}
for (const subAccount of subAccounts.value) {
if (subAccount.id) {
originalBalances.value.set(subAccount.id, subAccount.balance);
}
}
loading.value = false; loading.value = false;
}).catch(error => { }).catch(error => {
loading.value = false; loading.value = false;
@@ -337,7 +351,7 @@ function open(options?: { id?: string, currentAccount?: Account, category?: numb
}); });
} }
function save(): void { async function save(): Promise<void> {
const problemMessage = inputEmptyProblemMessage.value; const problemMessage = inputEmptyProblemMessage.value;
if (problemMessage) { if (problemMessage) {
@@ -347,6 +361,30 @@ function save(): void {
submitting.value = true; submitting.value = true;
// Collect balance adjustments needed for existing accounts
let balanceChanged = false;
if (editAccountId.value) {
const allAccounts = [account.value, ...subAccounts.value];
for (const acc of allAccounts) {
if (!acc.id || isNewAccount(acc)) continue;
const origBalance = originalBalances.value.get(acc.id);
if (origBalance !== undefined && acc.balance !== origBalance) {
balanceChanged = true;
try {
await transactionsStore.adjustAccountBalance({
accountId: acc.id,
targetBalance: acc.balance,
currentBalance: origBalance
});
} catch (error: unknown) {
submitting.value = false;
snackbar.value?.showError(error as { message: string });
return;
}
}
}
}
accountsStore.saveAccount({ accountsStore.saveAccount({
account: account.value, account: account.value,
subAccounts: subAccounts.value, subAccounts: subAccounts.value,
@@ -366,6 +404,12 @@ function save(): void {
}).catch(error => { }).catch(error => {
submitting.value = false; submitting.value = false;
if (balanceChanged && error.error && error.error.errorCode === KnownErrorCode.NothingWillBeUpdated) {
resolveFunc?.({ message: 'You have saved this account' });
showState.value = false;
return;
}
if (!error.processed) { if (!error.processed) {
snackbar.value?.showError(error); snackbar.value?.showError(error);
} }
@@ -211,6 +211,7 @@
:disabled="loading || submitting || !allVisibleAccounts.length || (mode === TransactionEditPageMode.Edit && transaction.type === TransactionType.ModifyBalance)" :disabled="loading || submitting || !allVisibleAccounts.length || (mode === TransactionEditPageMode.Edit && transaction.type === TransactionType.ModifyBalance)"
:enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')" :enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')"
:custom-selection-primary-text="sourceAccountName" :custom-selection-primary-text="sourceAccountName"
:custom-selection-secondary-text="sourceAccountBalanceDisplay"
:label="tt(sourceAccountTitle)" :label="tt(sourceAccountTitle)"
:placeholder="tt(sourceAccountTitle)" :placeholder="tt(sourceAccountTitle)"
:items="allVisibleCategorizedAccounts" :items="allVisibleCategorizedAccounts"
@@ -236,6 +237,7 @@
:disabled="loading || submitting || !allVisibleAccounts.length" :disabled="loading || submitting || !allVisibleAccounts.length"
:enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')" :enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')"
:custom-selection-primary-text="destinationAccountName" :custom-selection-primary-text="destinationAccountName"
:custom-selection-secondary-text="destinationAccountBalanceDisplay"
:label="tt('Destination Account')" :label="tt('Destination Account')"
:placeholder="tt('Destination Account')" :placeholder="tt('Destination Account')"
:items="allVisibleCategorizedAccounts" :items="allVisibleCategorizedAccounts"
@@ -510,6 +512,7 @@ import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import { TransactionTemplate } from '@/models/transaction_template.ts'; import { TransactionTemplate } from '@/models/transaction_template.ts';
import type { TransactionPictureInfoBasicResponse } from '@/models/transaction_picture_info.ts'; import type { TransactionPictureInfoBasicResponse } from '@/models/transaction_picture_info.ts';
import { Transaction } from '@/models/transaction.ts'; import { Transaction } from '@/models/transaction.ts';
import type { Account } from '@/models/account.ts';
import { import {
getTimezoneOffsetMinutes, getTimezoneOffsetMinutes,
@@ -567,7 +570,7 @@ const props = defineProps<{
show?: boolean; show?: boolean;
}>(); }>();
const { tt } = useI18n(); const { tt, formatAmountToLocalizedNumeralsWithCurrency } = useI18n();
const { const {
mode, mode,
@@ -644,6 +647,31 @@ const initOptions = ref<TransactionEditOptions | undefined>(undefined);
let resolveFunc: ((response?: TransactionEditResponse) => void) | null = null; let resolveFunc: ((response?: TransactionEditResponse) => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null; let rejectFunc: ((reason?: unknown) => void) | null = null;
function getAccountBalanceDisplay(account: Account): string {
if (account.creditLimit) {
const outstanding = -account.balance;
const available = account.creditLimit + account.balance;
return formatAmountToLocalizedNumeralsWithCurrency(outstanding, account.currency)
+ ' · ' + tt('Available') + ' ' + formatAmountToLocalizedNumeralsWithCurrency(available, account.currency);
}
const displayBalance = account.isLiability ? -account.balance : account.balance;
return formatAmountToLocalizedNumeralsWithCurrency(displayBalance, account.currency);
}
const sourceAccountBalanceDisplay = computed<string>(() => {
if (!transaction.value.sourceAccountId) return '';
const account = allVisibleAccounts.value.find(a => a.id === transaction.value.sourceAccountId);
if (!account) return '';
return getAccountBalanceDisplay(account);
});
const destinationAccountBalanceDisplay = computed<string>(() => {
if (!transaction.value.destinationAccountId) return '';
const account = allVisibleAccounts.value.find(a => a.id === transaction.value.destinationAccountId);
if (!account) return '';
return getAccountBalanceDisplay(account);
});
const sourceAmountColor = computed<string | undefined>(() => { const sourceAmountColor = computed<string | undefined>(() => {
if (transaction.value.type === TransactionType.Expense) { if (transaction.value.type === TransactionType.Expense) {
return 'expense'; return 'expense';
+41 -15
View File
@@ -222,7 +222,6 @@
<f7-list-item <f7-list-item
link="#" no-chevron link="#" no-chevron
class="list-item-with-header-and-title" class="list-item-with-header-and-title"
:class="{ 'disabled': editAccountId }"
:header="account.isLiability ? tt('Account Outstanding Balance') : tt('Account Balance')" :header="account.isLiability ? tt('Account Outstanding Balance') : tt('Account Balance')"
:title="formatAccountDisplayBalance(account)" :title="formatAccountDisplayBalance(account)"
@click="accountContext.showBalanceSheet = true" @click="accountContext.showBalanceSheet = true"
@@ -565,11 +564,13 @@ import { useI18nUIComponents, showLoading, hideLoading } from '@/lib/ui/mobile.t
import { useAccountEditPageBase } from '@/views/base/accounts/AccountEditPageBase.ts'; import { useAccountEditPageBase } from '@/views/base/accounts/AccountEditPageBase.ts';
import { useAccountsStore } from '@/stores/account.ts'; import { useAccountsStore } from '@/stores/account.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { itemAndIndex } from '@/core/base.ts'; import { itemAndIndex } from '@/core/base.ts';
import type { LocalizedCurrencyInfo } from '@/core/currency.ts'; import type { LocalizedCurrencyInfo } from '@/core/currency.ts';
import { AccountType } from '@/core/account.ts'; import { AccountType } from '@/core/account.ts';
import { ALL_ACCOUNT_ICONS } from '@/consts/icon.ts'; import { ALL_ACCOUNT_ICONS } from '@/consts/icon.ts';
import { KnownErrorCode } from '@/consts/api.ts';
import { ALL_ACCOUNT_COLORS } from '@/consts/color.ts'; import { ALL_ACCOUNT_COLORS } from '@/consts/color.ts';
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts'; import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts';
import type { Account } from '@/models/account.ts'; import type { Account } from '@/models/account.ts';
@@ -631,6 +632,7 @@ const {
} = useAccountEditPageBase(); } = useAccountEditPageBase();
const accountsStore = useAccountsStore(); const accountsStore = useAccountsStore();
const transactionsStore = useTransactionsStore();
const DEFAULT_ACCOUNT_CONTEXT: AccountContext = { const DEFAULT_ACCOUNT_CONTEXT: AccountContext = {
showIconSelectionSheet: false, showIconSelectionSheet: false,
@@ -643,6 +645,8 @@ const DEFAULT_ACCOUNT_CONTEXT: AccountContext = {
balanceDateTimeSheetMode: 'time' balanceDateTimeSheetMode: 'time'
}; };
const originalBalance = ref<number>(0);
const accountContext = ref<AccountContext>(Object.assign({}, DEFAULT_ACCOUNT_CONTEXT)); const accountContext = ref<AccountContext>(Object.assign({}, DEFAULT_ACCOUNT_CONTEXT));
const subAccountContexts = ref<AccountContext[]>([]); const subAccountContexts = ref<AccountContext[]>([]);
const subAccountToDelete = ref<Account | null>(null); const subAccountToDelete = ref<Account | null>(null);
@@ -697,6 +701,7 @@ function init(): void {
accountId: editAccountId.value accountId: editAccountId.value
}).then(response => { }).then(response => {
setAccount(response); setAccount(response);
originalBalance.value = response.balance;
subAccountContexts.value = []; subAccountContexts.value = [];
for (let i = 0; i < subAccounts.value.length; i++) { for (let i = 0; i < subAccounts.value.length; i++) {
@@ -729,22 +734,43 @@ function save(): void {
submitting.value = true; submitting.value = true;
showLoading(() => submitting.value); showLoading(() => submitting.value);
accountsStore.saveAccount({ const balanceChanged = !!editAccountId.value && account.value.balance !== originalBalance.value;
account: account.value, const adjustPromise = balanceChanged
subAccounts: subAccounts.value, ? transactionsStore.adjustAccountBalance({ accountId: editAccountId.value!, targetBalance: account.value.balance, currentBalance: originalBalance.value })
isEdit: !!editAccountId.value, : Promise.resolve(true);
clientSessionId: clientSessionId.value
}).then(() => {
submitting.value = false;
hideLoading();
if (!editAccountId.value) { adjustPromise.then(() => {
showToast('You have added a new account'); accountsStore.saveAccount({
} else { account: account.value,
showToast('You have saved this account'); subAccounts: subAccounts.value,
} isEdit: !!editAccountId.value,
clientSessionId: clientSessionId.value
}).then(() => {
submitting.value = false;
hideLoading();
router.back(); if (!editAccountId.value) {
showToast('You have added a new account');
} else {
showToast('You have saved this account');
}
router.back();
}).catch(error => {
submitting.value = false;
hideLoading();
if (balanceChanged && error.error && error.error.errorCode === KnownErrorCode.NothingWillBeUpdated) {
// Balance was adjusted but other fields unchanged — treat as success
showToast('You have saved this account');
router.back();
return;
}
if (!error.processed) {
showToast(error.message || error);
}
});
}).catch(error => { }).catch(error => {
submitting.value = false; submitting.value = false;
hideLoading(); hideLoading();
+30 -1
View File
@@ -195,6 +195,7 @@
:class="{ 'disabled': !allVisibleAccounts.length || (mode === TransactionEditPageMode.Edit && transaction.type === TransactionType.ModifyBalance), 'readonly': mode === TransactionEditPageMode.View }" :class="{ 'disabled': !allVisibleAccounts.length || (mode === TransactionEditPageMode.Edit && transaction.type === TransactionType.ModifyBalance), 'readonly': mode === TransactionEditPageMode.View }"
:header="tt(sourceAccountTitle)" :header="tt(sourceAccountTitle)"
:title="sourceAccountName" :title="sourceAccountName"
:footer="sourceAccountBalanceDisplay"
@click="showSourceAccountSheet = true" @click="showSourceAccountSheet = true"
> >
<two-column-list-item-selection-sheet primary-key-field="id" primary-value-field="category" <two-column-list-item-selection-sheet primary-key-field="id" primary-value-field="category"
@@ -218,6 +219,7 @@
:class="{ 'disabled': !allVisibleAccounts.length, 'readonly': mode === TransactionEditPageMode.View }" :class="{ 'disabled': !allVisibleAccounts.length, 'readonly': mode === TransactionEditPageMode.View }"
:header="tt('Destination Account')" :header="tt('Destination Account')"
:title="destinationAccountName" :title="destinationAccountName"
:footer="destinationAccountBalanceDisplay"
v-if="transaction.type === TransactionType.Transfer" v-if="transaction.type === TransactionType.Transfer"
@click="showDestinationAccountSheet = true" @click="showDestinationAccountSheet = true"
> >
@@ -523,6 +525,7 @@ import {
import { useSettingsStore } from '@/stores/setting.ts'; import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts'; import { useUserStore } from '@/stores/user.ts';
import { useAccountsStore } from '@/stores/account.ts'; import { useAccountsStore } from '@/stores/account.ts';
import type { Account } from '@/models/account.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts'; import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts'; import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { useTransactionsStore } from '@/stores/transaction.ts'; import { useTransactionsStore } from '@/stores/transaction.ts';
@@ -572,7 +575,8 @@ const {
formatDateTimeToLongDate, formatDateTimeToLongDate,
formatDateTimeToLongTime, formatDateTimeToLongTime,
formatGregorianTextualYearMonthDayToLongDate, formatGregorianTextualYearMonthDayToLongDate,
parseAmountFromLocalizedNumerals parseAmountFromLocalizedNumerals,
formatAmountToLocalizedNumeralsWithCurrency
} = useI18n(); } = useI18n();
const { showAlert, showConfirm, showToast, routeBackOnError } = useI18nUIComponents(); const { showAlert, showConfirm, showToast, routeBackOnError } = useI18nUIComponents();
@@ -679,6 +683,31 @@ const quickSaveButtonFloatingPosition = computed<string>(() => {
} }
}); });
function getAccountBalanceDisplay(account: Account): string {
if (account.creditLimit) {
const outstanding = -account.balance;
const available = account.creditLimit + account.balance;
return formatAmountToLocalizedNumeralsWithCurrency(outstanding, account.currency)
+ ' · ' + tt('Available') + ' ' + formatAmountToLocalizedNumeralsWithCurrency(available, account.currency);
}
const displayBalance = account.isLiability ? -account.balance : account.balance;
return formatAmountToLocalizedNumeralsWithCurrency(displayBalance, account.currency);
}
const sourceAccountBalanceDisplay = computed<string>(() => {
if (!transaction.value.sourceAccountId) return '';
const account = allVisibleAccounts.value.find(a => a.id === transaction.value.sourceAccountId);
if (!account) return '';
return getAccountBalanceDisplay(account);
});
const destinationAccountBalanceDisplay = computed<string>(() => {
if (!transaction.value.destinationAccountId) return '';
const account = allVisibleAccounts.value.find(a => a.id === transaction.value.destinationAccountId);
if (!account) return '';
return getAccountBalanceDisplay(account);
});
const sourceAmountClass = computed<Record<string, boolean>>(() => { const sourceAmountClass = computed<Record<string, boolean>>(() => {
const classes: Record<string, boolean> = { const classes: Record<string, boolean> = {
'readonly': mode.value === TransactionEditPageMode.View, 'readonly': mode.value === TransactionEditPageMode.View,