diff --git a/src/lib/category.ts b/src/lib/category.ts index 6347a59f..f3c0f9a0 100644 --- a/src/lib/category.ts +++ b/src/lib/category.ts @@ -26,7 +26,7 @@ export function categoryTypeToTransactionType(categoryType: CategoryType): Trans } } -export function getTransactionPrimaryCategoryName(categoryId: string, allCategories: TransactionCategory[]): string { +export function getTransactionPrimaryCategoryName(categoryId: string | null | undefined, allCategories: TransactionCategory[]): string { if (!allCategories) { return ''; } @@ -49,7 +49,7 @@ export function getTransactionPrimaryCategoryName(categoryId: string, allCategor return ''; } -export function getTransactionSecondaryCategoryName(categoryId: string, allCategories: TransactionCategory[]): string { +export function getTransactionSecondaryCategoryName(categoryId: string | null | undefined, allCategories: TransactionCategory[]): string { if (!allCategories) { return ''; } diff --git a/src/locales/helpers.ts b/src/locales/helpers.ts index 4615874d..ead3b106 100644 --- a/src/locales/helpers.ts +++ b/src/locales/helpers.ts @@ -90,6 +90,12 @@ import { ALL_CURRENCIES } from '@/consts/currency.ts'; import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES, DEFAULT_TRANSFER_CATEGORIES } from '@/consts/category.ts'; import { KnownErrorCode, SPECIFIED_API_NOT_FOUND_ERRORS, PARAMETERIZED_ERRORS } from '@/consts/api.ts'; +import { + type CategorizedAccount, + Account, + AccountWithDisplayBalance, + CategorizedAccountWithDisplayBalance +} from '@/models/account.ts'; import type { LatestExchangeRateResponse, LocalizedLatestExchangeRate } from '@/models/exchange_rate.ts'; import { @@ -98,6 +104,7 @@ import { isString, isNumber, isBoolean, + copyObjectTo, copyArrayTo } from '@/lib/common.ts'; @@ -132,11 +139,17 @@ import { getAmountPrependAndAppendCurrencySymbol } from '@/lib/currency.ts'; +import { + getCategorizedAccountsMap, + getAllFilteredAccountsBalance +} from '@/lib/account.ts'; + import services from '@/lib/services.ts'; import logger from '@/lib/logger.ts'; import { useSettingsStore } from '@/stores/setting.ts'; import { useUserStore } from '@/stores/user.ts'; +import { useExchangeRatesStore } from '@/stores/exchangeRates.ts'; export interface LocalizedErrorParameter { readonly key: string; @@ -177,6 +190,7 @@ export function useI18n() { const settingsStore = useSettingsStore(); const userStore = useUserStore(); + const exchangeRatesStore = useExchangeRatesStore(); // private functions function getLanguageDisplayName(languageName: string): string { @@ -1371,6 +1385,87 @@ export function useI18n() { return getAmountPrependAndAppendCurrencySymbol(currencyDisplayType, currencyCode, currencyUnit, currencyName, isPlural); } + function getCategorizedAccountsWithDisplayBalance(allVisibleAccounts: Account[], showAccountBalance: boolean): CategorizedAccountWithDisplayBalance[] { + const ret: CategorizedAccountWithDisplayBalance[] = []; + const defaultCurrency = userStore.currentUserDefaultCurrency; + const allCategories = AccountCategory.values(); + const categorizedAccounts: Record = copyObjectTo(getCategorizedAccountsMap(allVisibleAccounts), {}) as Record; + + for (let i = 0; i < allCategories.length; i++) { + const category = allCategories[i]; + + if (!categorizedAccounts[category.type]) { + continue; + } + + const accountCategory = categorizedAccounts[category.type]; + const accountsWithDisplayBalance: AccountWithDisplayBalance[] = []; + + if (accountCategory.accounts) { + for (let i = 0; i < accountCategory.accounts.length; i++) { + const account = accountCategory.accounts[i]; + let accountWithDisplaceBalance: AccountWithDisplayBalance; + + if (showAccountBalance && account.isAsset) { + accountWithDisplaceBalance = AccountWithDisplayBalance.fromAccount(account, getFormattedAmountWithCurrency(account.balance, account.currency) as string); + } else if (showAccountBalance && account.isLiability) { + accountWithDisplaceBalance = AccountWithDisplayBalance.fromAccount(account, getFormattedAmountWithCurrency(-account.balance, account.currency) as string); + } else { + accountWithDisplaceBalance = AccountWithDisplayBalance.fromAccount(account, '***'); + } + + accountsWithDisplayBalance.push(accountWithDisplaceBalance); + } + } + + let finalTotalBalance = ''; + + if (showAccountBalance) { + const accountsBalance = getAllFilteredAccountsBalance(categorizedAccounts, account => account.category === accountCategory.category); + let totalBalance = 0; + let hasUnCalculatedAmount = false; + + for (let i = 0; i < accountsBalance.length; i++) { + if (accountsBalance[i].currency === defaultCurrency) { + if (accountsBalance[i].isAsset) { + totalBalance += accountsBalance[i].balance; + } else if (accountsBalance[i].isLiability) { + totalBalance -= accountsBalance[i].balance; + } + } else { + const balance = exchangeRatesStore.getExchangedAmount(accountsBalance[i].balance, accountsBalance[i].currency, defaultCurrency); + + if (!isNumber(balance)) { + hasUnCalculatedAmount = true; + continue; + } + + if (accountsBalance[i].isAsset) { + totalBalance += Math.floor(balance); + } else if (accountsBalance[i].isLiability) { + totalBalance -= Math.floor(balance); + } + } + } + + finalTotalBalance = totalBalance.toString(); + + if (hasUnCalculatedAmount) { + finalTotalBalance = finalTotalBalance + '+'; + } + + finalTotalBalance = getFormattedAmountWithCurrency(finalTotalBalance, defaultCurrency) as string; + } else { + finalTotalBalance = '***'; + } + + const accountCategoryWithDisplayBalance = CategorizedAccountWithDisplayBalance.of(accountCategory, accountsWithDisplayBalance, finalTotalBalance); + ret.push(accountCategoryWithDisplayBalance); + } + + return ret; + } + function setLanguage(languageKey: string | null, force?: boolean): LocaleDefaultSettings | null { if (!languageKey) { languageKey = getDefaultLanguage(); @@ -1547,6 +1642,7 @@ export function useI18n() { formatExchangeRateAmount: getFormattedExchangeRateAmount, getAdaptiveAmountRate, getAmountPrependAndAppendText, + getCategorizedAccountsWithDisplayBalance, // localization setting functions setLanguage, setTimeZone, diff --git a/src/models/account.ts b/src/models/account.ts index 3ab62508..d372be08 100644 --- a/src/models/account.ts +++ b/src/models/account.ts @@ -23,7 +23,7 @@ export class Account implements AccountInfoResponse { public visible: boolean; public childrenAccounts?: Account[]; - private constructor(id: string, name: string, parentId: string, category: number, type: number, icon: string, color: string, currency: string, balance: number, comment: string, displayOrder: number, visible: boolean, balanceTime?: number, creditCardStatementDate?: number, isAsset?: boolean, isLiability?: boolean, childrenAccounts?: Account[]) { + protected constructor(id: string, name: string, parentId: string, category: number, type: number, icon: string, color: string, currency: string, balance: number, comment: string, displayOrder: number, visible: boolean, balanceTime?: number, creditCardStatementDate?: number, isAsset?: boolean, isLiability?: boolean, childrenAccounts?: Account[]) { this.id = id; this.name = name; this.parentId = parentId; @@ -303,6 +303,38 @@ export class Account implements AccountInfoResponse { } } +export class AccountWithDisplayBalance extends Account { + public displayBalance: string; + + private constructor(Account: Account, displayBalance: string) { + super( + Account.id, + Account.name, + Account.parentId, + Account.category, + Account.type, + Account.icon, + Account.color, + Account.currency, + Account.balance, + Account.comment, + Account.displayOrder, + Account.visible, + Account.balanceTime, + Account.creditCardStatementDate, + Account.isAsset, + Account.isLiability, + Account.childrenAccounts + ); + + this.displayBalance = displayBalance; + } + + public static fromAccount(account: Account, displayBalance: string): AccountWithDisplayBalance { + return new AccountWithDisplayBalance(account, displayBalance); + } +} + export interface AccountCreateRequest { readonly name: string; readonly category: number; @@ -386,6 +418,26 @@ export interface CategorizedAccount { readonly accounts: Account[]; } +export class CategorizedAccountWithDisplayBalance { + public category: number; + public name: string; + public icon: string; + public accounts: AccountWithDisplayBalance[]; + public displayBalance: string; + + private constructor(category: number, name: string, icon: string, accounts: AccountWithDisplayBalance[], displayBalance: string) { + this.category = category; + this.name = name; + this.icon = icon; + this.accounts = accounts; + this.displayBalance = displayBalance; + } + + public static of(categorizedAccount: CategorizedAccount, accounts: AccountWithDisplayBalance[], displayBalance: string): CategorizedAccountWithDisplayBalance { + return new CategorizedAccountWithDisplayBalance(categorizedAccount.category, categorizedAccount.name, categorizedAccount.icon, accounts, displayBalance); + } +} + export interface AccountCategoriesWithVisibleCount { readonly category: number; readonly name: string; diff --git a/src/views/desktop/transactions/list/dialogs/BatchReplaceDialog.vue b/src/views/desktop/transactions/list/dialogs/BatchReplaceDialog.vue index 710a4264..525b67ca 100644 --- a/src/views/desktop/transactions/list/dialogs/BatchReplaceDialog.vue +++ b/src/views/desktop/transactions/list/dialogs/BatchReplaceDialog.vue @@ -3,16 +3,16 @@ @@ -22,10 +22,10 @@ item-title="name" item-value="value" persistent-placeholder - :label="$t('Invalid Category')" - :placeholder="$t('Invalid Category')" + :label="tt('Invalid Category')" + :placeholder="tt('Invalid Category')" :items="invalidItems" - :no-data-text="$t('No available category')" + :no-data-text="tt('No available category')" v-model="sourceItem"> @@ -38,11 +38,11 @@ secondary-hidden-field="hidden" :disabled="!hasAvailableExpenseCategories" :show-selection-primary-text="true" - :custom-selection-primary-text="getPrimaryCategoryName(targetItem, allCategories[allCategoryTypes.Expense])" - :custom-selection-secondary-text="getSecondaryCategoryName(targetItem, allCategories[allCategoryTypes.Expense])" - :label="$t('Target Category')" - :placeholder="$t('Target Category')" - :items="allCategories[allCategoryTypes.Expense]" + :custom-selection-primary-text="getTransactionPrimaryCategoryName(targetItem, allCategories[CategoryType.Expense])" + :custom-selection-secondary-text="getTransactionSecondaryCategoryName(targetItem, allCategories[CategoryType.Expense])" + :label="tt('Target Category')" + :placeholder="tt('Target Category')" + :items="allCategories[CategoryType.Expense]" v-model="targetItem" v-if="type === 'expenseCategory'"> @@ -54,11 +54,11 @@ secondary-hidden-field="hidden" :disabled="!hasAvailableIncomeCategories" :show-selection-primary-text="true" - :custom-selection-primary-text="getPrimaryCategoryName(targetItem, allCategories[allCategoryTypes.Income])" - :custom-selection-secondary-text="getSecondaryCategoryName(targetItem, allCategories[allCategoryTypes.Income])" - :label="$t('Target Category')" - :placeholder="$t('Target Category')" - :items="allCategories[allCategoryTypes.Income]" + :custom-selection-primary-text="getTransactionPrimaryCategoryName(targetItem, allCategories[CategoryType.Income])" + :custom-selection-secondary-text="getTransactionSecondaryCategoryName(targetItem, allCategories[CategoryType.Income])" + :label="tt('Target Category')" + :placeholder="tt('Target Category')" + :items="allCategories[CategoryType.Income]" v-model="targetItem" v-if="type === 'incomeCategory'"> @@ -70,11 +70,11 @@ secondary-hidden-field="hidden" :disabled="!hasAvailableTransferCategories" :show-selection-primary-text="true" - :custom-selection-primary-text="getPrimaryCategoryName(targetItem, allCategories[allCategoryTypes.Transfer])" - :custom-selection-secondary-text="getSecondaryCategoryName(targetItem, allCategories[allCategoryTypes.Transfer])" - :label="$t('Target Category')" - :placeholder="$t('Target Category')" - :items="allCategories[allCategoryTypes.Transfer]" + :custom-selection-primary-text="getTransactionPrimaryCategoryName(targetItem, allCategories[CategoryType.Transfer])" + :custom-selection-secondary-text="getTransactionSecondaryCategoryName(targetItem, allCategories[CategoryType.Transfer])" + :label="tt('Target Category')" + :placeholder="tt('Target Category')" + :items="allCategories[CategoryType.Transfer]" v-model="targetItem" v-if="type === 'transferCategory'"> @@ -88,10 +88,10 @@ item-title="name" item-value="value" persistent-placeholder - :label="$t('Invalid Account')" - :placeholder="$t('Invalid Account')" + :label="tt('Invalid Account')" + :placeholder="tt('Invalid Account')" :items="invalidItems" - :no-data-text="$t('No available account')" + :no-data-text="tt('No available account')" v-model="sourceItem"> @@ -106,8 +106,8 @@ secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color" :disabled="!allVisibleAccounts.length" :custom-selection-primary-text="getAccountDisplayName(targetItem)" - :label="$t('Target Account')" - :placeholder="$t('Target Account')" + :label="tt('Target Account')" + :placeholder="tt('Target Account')" :items="allVisibleCategorizedAccounts" v-model="targetItem"> @@ -121,10 +121,10 @@ item-title="name" item-value="value" persistent-placeholder - :label="$t('Invalid Tag')" - :placeholder="$t('Invalid Tag')" + :label="tt('Invalid Tag')" + :placeholder="tt('Invalid Tag')" :items="invalidItems" - :no-data-text="$t('No available tag')" + :no-data-text="tt('No available tag')" v-model="sourceItem"> @@ -134,10 +134,10 @@ item-value="id" persistent-placeholder chips - :label="$t('Target Tag')" - :placeholder="$t('Target Tag')" + :label="tt('Target Tag')" + :placeholder="tt('Target Tag')" :items="allTags" - :no-data-text="$t('No available tag')" + :no-data-text="tt('No available tag')" v-model="targetItem" > -