From c7c84c74d3730a655c06d2ecef66ca2b441a538a Mon Sep 17 00:00:00 2001 From: Zhengchen Tao Date: Sun, 5 Apr 2026 18:40:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A6=E6=88=B7=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E8=B0=83=E6=95=B4=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=B0=83=E6=95=B4=E4=BD=99=E9=A2=9D=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BB=A5=E6=98=BE=E7=A4=BA=E8=B4=A6=E6=88=B7=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E5=92=8C=E5=8F=AF=E7=94=A8=E9=A2=9D=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE=E4=BB=A5?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=95=88=E6=9E=9C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MY_REQUIREMENTS.md | 93 ++++++++++--------- pkg/models/transaction.go | 4 +- pkg/services/transactions.go | 13 +-- src/router/mobile.ts | 7 -- src/stores/transaction.ts | 57 +++++++++++- .../accounts/list/dialogs/EditDialog.vue | 48 +++++++++- .../transactions/list/dialogs/EditDialog.vue | 30 +++++- src/views/mobile/accounts/EditPage.vue | 56 ++++++++--- src/views/mobile/transactions/EditPage.vue | 31 ++++++- 9 files changed, 255 insertions(+), 84 deletions(-) diff --git a/MY_REQUIREMENTS.md b/MY_REQUIREMENTS.md index 5d251566..5ddd1922 100644 --- a/MY_REQUIREMENTS.md +++ b/MY_REQUIREMENTS.md @@ -1,7 +1,7 @@ # ezBookkeeping 个人需求清单 > 基于 fork 版本的定制开发需求,持续更新。 -> 标注:✅ 易实现 | ⚠️ 中等难度 | ❌ 难/暂缓 | ❓ 待确认 | 🟢 已完成 +> 标注:⚠️ 中等难度 | ❌ 难/暂缓 | 🟢 已完成 --- @@ -22,15 +22,18 @@ ## 二、账户功能 -### 2. ⚠️ 信用卡账户:额度与剩余额度 -**描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表和详情中展示「已用额度」和「剩余额度」。 +### 2. 🟢 信用卡账户:额度与剩余额度 +**描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表显示「可用额度」。 -**实现思路:** -- 后端:账户数据库表新增 `credit_limit` 字段,修改 API -- 前端:账户编辑页增加额度输入,账户列表/详情显示剩余额度(= 额度 - 欠款) -- 涉及文件:后端 `pkg/models/account.go`、前端 `EditPage.vue`、`ListPage.vue` +**已完成:** +- 后端:`AccountExtend` JSON blob 新增 `CreditLimit` 字段(无需数据库迁移) +- API:`AccountCreateRequest` / `AccountModifyRequest` / `AccountInfoResponse` 增加 `creditLimit` +- 前端 model:`Account` 类增加 `creditLimit` 字段,同步 `toCreateRequest` / `toModifyRequest` +- 移动端 EditPage:CreditCard 分类时显示信用额度输入项 +- 移动端 ListPage:账户列表显示「可用额度」(= `creditLimit + balance`,因信用卡余额为负值) +- 语言包:中英文均已添加 "Credit Limit" / "Available" -### 8. ⚠️ 账户交易列表顶部显示账户信息 +### 3. ⚠️ 账户交易列表顶部显示账户信息 **描述:** 在按账户筛选的交易列表页面顶部,显示账户名称、余额(或信用卡欠款+额度)等信息卡片。 **实现思路:** @@ -39,28 +42,31 @@ - 若多账户筛选则显示多张卡片 - 涉及文件:`src/views/mobile/transactions/ListPage.vue` -### 9. ✅ 允许直接修改账户余额(自动插入调整记录) -**描述:** 在账户详情提供「调整余额」入口,输入目标余额后,自动计算差值并插入一条「余额调整」类型交易记录。 +### 4. 🟢 允许直接修改账户余额(自动插入调整记录) +**描述:** 在账户编辑页直接修改余额字段,保存时自动计算差值并插入一条「余额调整」类型交易记录。 -**现状:** 代码中已存在 `ModifyBalance` 交易类型,可能已有后端支持,仅需确认前端入口是否存在。 - -**实现思路:** -- 确认 `ModifyBalance` 交易类型的前端入口 -- 若无,在账户详情页增加「调整余额」按钮,跳转至记账页并预填该类型 +**已完成:** +- 后端:移除「账户已有交易时不允许添加 ModifyBalance」限制 +- 后端:`Amount` 和 `RelatedAccountAmount` 均存储 delta(而非目标余额);删除该交易可撤销 delta +- 前端 store:新增 `adjustAccountBalance({ accountId, targetBalance, currentBalance })` 函数 +- 移动端 EditPage:余额字段对已有账户解除只读,保存时若余额变化先调用 `adjustAccountBalance`;捕获 `NothingWillBeUpdated (200004)` 视为成功 +- 桌面端 EditDialog:同上逻辑,支持多子账户逐一调整 --- ## 三、记账页面 -### 3. ✅ 记账页显示当前账户余额 / 欠款+额度 -**描述:** 在记账页面选择账户后,在账户名称旁或下方显示该账户的当前余额(普通账户)或欠款+额度(信用卡)。 +### 5. 🟢 记账页显示当前账户余额 / 欠款+额度 +**描述:** 在记账页面选择账户后,在账户名称下方显示该账户的当前余额(普通账户)或欠款金额(信用卡/负债)。 -**实现思路:** -- 纯前端改动,从 account store 读取余额信息 -- 在账户选择区域旁增加余额展示 -- 涉及文件:`src/views/mobile/transactions/EditPage.vue` +**已完成:** +- 信用卡账户显示「可用额度: ¥xxx」(= creditLimit + balance) +- 普通负债账户显示欠款正数,普通资产账户显示余额 +- 转账时源账户和目标账户均显示 +- 移动端:账户行 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 切换无动画 + 全局动画加速 -**描述:** 底部 5 个主 Tab 切换无动画,其余动画加速。 +### 10. 🟢 全局动画加速 +**描述:** 全局页面跳转及各类弹层动画加速。 **已完成:** -- 5 个主 Tab(首页、交易列表、账户、统计、设置)路由加 `animate: false` - 全局动画时长从 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,减少长按判定等待 - 涉及文件:`src/MobileApp.vue` -**未完成(已放弃):** -- HomePage 预加载 tags 数据(用户已 revert) - --- ## 八、离线 / 缓存 -### 5. ❌ 本地优先 / 离线数据缓存(暂缓) +### 12. ❌ 本地优先 / 离线数据缓存(暂缓) **描述:** 交易数据缓存至本地,优先展示缓存数据,后台静默拉取最新数据并更新。 **现状:** Service Worker 已实现静态资源缓存(图片、字体、地图瓦片),但**交易业务数据**目前不做本地缓存。 @@ -159,14 +162,14 @@ | # | 需求 | 状态 | |---|------|------| | 1 | 自选主题色 | ⚠️ 待做 | -| 2 | 信用卡额度 | ⚠️ 待做(需改后端) | -| 3 | 记账页显示余额 | ✅ 待做 | -| 4 | 点击卡顿优化 | 🟢 部分完成 | -| 5 | 离线缓存 | ❌ 暂缓 | -| 6 | 小键盘布局 | 🟢 已完成 | -| 7 | 详情编辑/删除 | 🟢 已完成 | -| 8 | 账户信息卡片 | ⚠️ 待做 | -| 9 | 调整余额入口 | ✅ 待做 | -| 10 | 分类默认展开 | ✅ 待做 | -| 11 | 记住上次账户 | ✅ 待做 | -| 12 | Tab 无动画 + 加速 | 🟢 已完成 | +| 2 | 信用卡额度 | 🟢 已完成 | +| 3 | 账户信息卡片 | ⚠️ 待做 | +| 4 | 调整余额入口 | 🟢 已完成 | +| 5 | 记账页显示余额 | 🟢 已完成 | +| 6 | 记住上次账户 | ❓ 待定 | +| 7 | 小键盘布局 | 🟢 已完成 | +| 8 | 详情编辑/删除 | 🟢 已完成 | +| 9 | 分类默认展开 | ❓ 待定 | +| 10 | 全局动画加速 | 🟢 已完成 | +| 11 | 点击卡顿优化 | 🟢 已完成 | +| 12 | 离线缓存 | ❌ 暂缓 | diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 28e1cf85..33f7480d 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -544,7 +544,9 @@ func (t *Transaction) ToTransactionInfoResponse(tagIds []int64, editable bool) * destinationAccountId := 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 destinationAmount = t.RelatedAccountAmount } else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN { diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go index 25875b0b..9205a957 100644 --- a/pkg/services/transactions.go +++ b/pkg/services/transactions.go @@ -954,7 +954,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode if transaction.Amount != oldTransaction.Amount { 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") } @@ -2251,17 +2251,8 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas // Verify balance modification transaction and calculate real amount 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.RelatedAccountAmount = transaction.Amount - sourceAccount.Balance + transaction.RelatedAccountAmount = transaction.Amount // Amount IS the delta } else { // Not allow to add transaction before balance modification transaction otherTransactionExists := false diff --git a/src/router/mobile.ts b/src/router/mobile.ts index 0406b063..b15e9235 100644 --- a/src/router/mobile.ts +++ b/src/router/mobile.ts @@ -128,9 +128,6 @@ const routes: Router.RouteParameters[] = [ path: '/', async: asyncResolve(HomePage), beforeEnter: [checkLogin], - options: { - animate: false, - } }, { path: '/login', @@ -160,7 +157,6 @@ const routes: Router.RouteParameters[] = [ path: '/transaction/list', async: asyncResolve(TransactionListPage), beforeEnter: [checkLogin], - options: { animate: false } }, { path: '/transaction/filter/amount', @@ -186,7 +182,6 @@ const routes: Router.RouteParameters[] = [ path: '/account/list', async: asyncResolve(AccountListPage), beforeEnter: [checkLogin], - options: { animate: false } }, { path: '/account/add', @@ -212,7 +207,6 @@ const routes: Router.RouteParameters[] = [ path: '/statistic/transaction', async: asyncResolve(StatisticsTransactionPage), beforeEnter: [checkLogin], - options: { animate: false } }, { path: '/statistic/settings', @@ -263,7 +257,6 @@ const routes: Router.RouteParameters[] = [ path: '/settings', async: asyncResolve(SettingsPage), beforeEnter: [checkLogin], - options: { animate: false } }, { path: '/app_lock', diff --git a/src/stores/transaction.ts b/src/stores/transaction.ts index 5cf2b338..b73f9fe7 100644 --- a/src/stores/transaction.ts +++ b/src/stores/transaction.ts @@ -54,11 +54,12 @@ import { splitItemsToMap, countSplitItems } 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 { getCurrencyFraction } from '@/lib/currency.ts'; import { getFirstVisibleCategoryId } from '@/lib/category.ts'; import services, { type ApiResponsePromise } from '@/lib/services.ts'; +import { generateRandomUUID } from '@/lib/misc.ts'; import logger from '@/lib/logger.ts'; export interface TransactionListPartialFilter { @@ -1166,6 +1167,59 @@ export const useTransactionsStore = defineStore('transactions', () => { }); } + function adjustAccountBalance({ accountId, targetBalance, currentBalance }: { accountId: string, targetBalance: number, currentBalance: number }): Promise { + 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 { return new Promise((resolve, reject) => { services.deleteTransaction({ @@ -1472,6 +1526,7 @@ export const useTransactionsStore = defineStore('transactions', () => { getReconciliationStatements, getTransaction, saveTransaction, + adjustAccountBalance, moveAllTransactionsBetweenAccounts, deleteTransaction, recognizeReceiptImage, diff --git a/src/views/desktop/accounts/list/dialogs/EditDialog.vue b/src/views/desktop/accounts/list/dialogs/EditDialog.vue index 0124d73b..7f7df536 100644 --- a/src/views/desktop/accounts/list/dialogs/EditDialog.vue +++ b/src/views/desktop/accounts/list/dialogs/EditDialog.vue @@ -131,7 +131,7 @@ - ('confirmDialog'); const snackbar = useTemplateRef('snackbar'); @@ -261,6 +265,7 @@ const snackbar = useTemplateRef('snackbar'); const showState = ref(false); const activeTab = ref('account'); const currentAccountIndex = ref(-1); +const originalBalances = ref>(new Map()); const selectedAccount = computed(() => { if (currentAccountIndex.value < 0) { @@ -310,6 +315,15 @@ function open(options?: { id?: string, currentAccount?: Account, category?: numb accountId: editAccountId.value }).then(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; }).catch(error => { loading.value = false; @@ -337,7 +351,7 @@ function open(options?: { id?: string, currentAccount?: Account, category?: numb }); } -function save(): void { +async function save(): Promise { const problemMessage = inputEmptyProblemMessage.value; if (problemMessage) { @@ -347,6 +361,30 @@ function save(): void { 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({ account: account.value, subAccounts: subAccounts.value, @@ -366,6 +404,12 @@ function save(): void { }).catch(error => { 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) { snackbar.value?.showError(error); } diff --git a/src/views/desktop/transactions/list/dialogs/EditDialog.vue b/src/views/desktop/transactions/list/dialogs/EditDialog.vue index 5876b26a..a28b114f 100644 --- a/src/views/desktop/transactions/list/dialogs/EditDialog.vue +++ b/src/views/desktop/transactions/list/dialogs/EditDialog.vue @@ -211,6 +211,7 @@ :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')" :custom-selection-primary-text="sourceAccountName" + :custom-selection-secondary-text="sourceAccountBalanceDisplay" :label="tt(sourceAccountTitle)" :placeholder="tt(sourceAccountTitle)" :items="allVisibleCategorizedAccounts" @@ -236,6 +237,7 @@ :disabled="loading || submitting || !allVisibleAccounts.length" :enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')" :custom-selection-primary-text="destinationAccountName" + :custom-selection-secondary-text="destinationAccountBalanceDisplay" :label="tt('Destination Account')" :placeholder="tt('Destination Account')" :items="allVisibleCategorizedAccounts" @@ -510,6 +512,7 @@ import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts'; import { TransactionTemplate } from '@/models/transaction_template.ts'; import type { TransactionPictureInfoBasicResponse } from '@/models/transaction_picture_info.ts'; import { Transaction } from '@/models/transaction.ts'; +import type { Account } from '@/models/account.ts'; import { getTimezoneOffsetMinutes, @@ -567,7 +570,7 @@ const props = defineProps<{ show?: boolean; }>(); -const { tt } = useI18n(); +const { tt, formatAmountToLocalizedNumeralsWithCurrency } = useI18n(); const { mode, @@ -644,6 +647,31 @@ const initOptions = ref(undefined); let resolveFunc: ((response?: TransactionEditResponse) => 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(() => { + 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(() => { + 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(() => { if (transaction.value.type === TransactionType.Expense) { return 'expense'; diff --git a/src/views/mobile/accounts/EditPage.vue b/src/views/mobile/accounts/EditPage.vue index 874bc351..9d1f690b 100644 --- a/src/views/mobile/accounts/EditPage.vue +++ b/src/views/mobile/accounts/EditPage.vue @@ -222,7 +222,6 @@ (0); + const accountContext = ref(Object.assign({}, DEFAULT_ACCOUNT_CONTEXT)); const subAccountContexts = ref([]); const subAccountToDelete = ref(null); @@ -697,6 +701,7 @@ function init(): void { accountId: editAccountId.value }).then(response => { setAccount(response); + originalBalance.value = response.balance; subAccountContexts.value = []; for (let i = 0; i < subAccounts.value.length; i++) { @@ -729,22 +734,43 @@ function save(): void { submitting.value = true; showLoading(() => submitting.value); - accountsStore.saveAccount({ - account: account.value, - subAccounts: subAccounts.value, - isEdit: !!editAccountId.value, - clientSessionId: clientSessionId.value - }).then(() => { - submitting.value = false; - hideLoading(); + const balanceChanged = !!editAccountId.value && account.value.balance !== originalBalance.value; + const adjustPromise = balanceChanged + ? transactionsStore.adjustAccountBalance({ accountId: editAccountId.value!, targetBalance: account.value.balance, currentBalance: originalBalance.value }) + : Promise.resolve(true); - if (!editAccountId.value) { - showToast('You have added a new account'); - } else { - showToast('You have saved this account'); - } + adjustPromise.then(() => { + accountsStore.saveAccount({ + account: account.value, + 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 => { submitting.value = false; hideLoading(); diff --git a/src/views/mobile/transactions/EditPage.vue b/src/views/mobile/transactions/EditPage.vue index 6f87e9e7..ffe280fb 100644 --- a/src/views/mobile/transactions/EditPage.vue +++ b/src/views/mobile/transactions/EditPage.vue @@ -195,6 +195,7 @@ :class="{ 'disabled': !allVisibleAccounts.length || (mode === TransactionEditPageMode.Edit && transaction.type === TransactionType.ModifyBalance), 'readonly': mode === TransactionEditPageMode.View }" :header="tt(sourceAccountTitle)" :title="sourceAccountName" + :footer="sourceAccountBalanceDisplay" @click="showSourceAccountSheet = true" > @@ -523,6 +525,7 @@ import { import { useSettingsStore } from '@/stores/setting.ts'; import { useUserStore } from '@/stores/user.ts'; import { useAccountsStore } from '@/stores/account.ts'; +import type { Account } from '@/models/account.ts'; import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts'; import { useTransactionTagsStore } from '@/stores/transactionTag.ts'; import { useTransactionsStore } from '@/stores/transaction.ts'; @@ -572,7 +575,8 @@ const { formatDateTimeToLongDate, formatDateTimeToLongTime, formatGregorianTextualYearMonthDayToLongDate, - parseAmountFromLocalizedNumerals + parseAmountFromLocalizedNumerals, + formatAmountToLocalizedNumeralsWithCurrency } = useI18n(); const { showAlert, showConfirm, showToast, routeBackOnError } = useI18nUIComponents(); @@ -679,6 +683,31 @@ const quickSaveButtonFloatingPosition = computed(() => { } }); +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(() => { + 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(() => { + 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>(() => { const classes: Record = { 'readonly': mode.value === TransactionEditPageMode.View,