Files
ezbookkeeping/src/views/desktop/transactions/import/tabs/ImportTransactionCheckDataTab.vue
T

1860 lines
81 KiB
Vue

<template>
<v-data-table
fixed-header
fixed-footer
show-select
multi-sort
density="compact"
item-value="index"
:class="{ 'import-transaction-table': true, 'disabled': !!disabled }"
:height="importTransactionsTableHeight"
:headers="importTransactionHeaders"
:items="importTransactions"
:search="JSON.stringify(filters)"
:custom-filter="importTransactionsFilter"
:no-data-text="tt('No data to import')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
>
<template #header.data-table-select>
<v-checkbox readonly class="always-cursor-pointer"
density="compact" width="28"
:disabled="!!disabled"
:indeterminate="anyButNotAllTransactionSelected"
v-model="allTransactionSelected"
>
<v-menu activator="parent" location="bottom">
<v-list>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All Valid Items')"
:disabled="!!disabled"
@click="selectAllValid"></v-list-item>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All Invalid Items')"
:disabled="!!disabled"
@click="selectAllInvalid"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All')"
:disabled="!!disabled"
@click="selectAll"></v-list-item>
<v-list-item :prepend-icon="mdiSelect"
:title="tt('Select None')"
:disabled="!!disabled"
@click="selectNone"></v-list-item>
<v-list-item :prepend-icon="mdiSelectInverse"
:title="tt('Invert Selection')"
:disabled="!!disabled"
@click="selectInvert"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All in This Page')"
:disabled="!!disabled"
@click="selectAllInThisPage"></v-list-item>
<v-list-item :prepend-icon="mdiSelect"
:title="tt('Select None in This Page')"
:disabled="!!disabled"
@click="selectNoneInThisPage"></v-list-item>
<v-list-item :prepend-icon="mdiSelectInverse"
:title="tt('Invert Selection in This Page')"
:disabled="!!disabled"
@click="selectInvertInThisPage"></v-list-item>
</v-list>
</v-menu>
</v-checkbox>
</template>
<template #item.data-table-select="{ item }">
<v-checkbox density="compact"
:color="!item.valid ? 'error' : 'primary'"
:disabled="!!disabled"
v-model="item.selected"></v-checkbox>
</template>
<template #item.valid="{ item }">
<v-icon size="small" :class="{ 'text-error': !item.valid }"
:disabled="!!disabled"
:icon="editingTransaction === item ? mdiCheck : mdiPencilOutline"
@click="editTransaction(item)">
</v-icon>
<v-tooltip activator="parent" v-if="!disabled">{{ tt('Edit') }}</v-tooltip>
</template>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ms-1" variant="flat" color="grey" size="x-small"
v-if="item.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(item) }}</v-chip>
</template>
<template #item.type="{ value }">
<v-chip label color="secondary" variant="outlined" size="x-small" v-if="value === TransactionType.ModifyBalance">{{ tt('Modify Balance') }}</v-chip>
<v-chip label class="text-income" variant="outlined" size="x-small" v-else-if="value === TransactionType.Income">{{ tt('Income') }}</v-chip>
<v-chip label class="text-expense" variant="outlined" size="x-small" v-else-if="value === TransactionType.Expense">{{ tt('Expense') }}</v-chip>
<v-chip label color="primary" variant="outlined" size="x-small" v-else-if="value === TransactionType.Transfer">{{ tt('Transfer') }}</v-chip>
<v-chip label color="default" variant="outlined" size="x-small" v-else>{{ tt('Unknown') }}</v-chip>
</template>
<template #item.actualCategoryName="{ item }">
<div class="d-flex align-center" v-if="editingTransaction !== item || item.type === TransactionType.ModifyBalance">
<span v-if="item.type === TransactionType.ModifyBalance">-</span>
<ItemIcon size="24px" icon-type="category"
:icon-id="allCategoriesMap[item.categoryId].icon"
:color="allCategoriesMap[item.categoryId].color"
v-if="item.type !== TransactionType.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]"></ItemIcon>
<span class="ms-2" v-if="item.type !== TransactionType.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]">
{{ allCategoriesMap[item.categoryId].name }}
</span>
<div class="text-error font-italic" v-else-if="item.type !== TransactionType.ModifyBalance && (!item.categoryId || item.categoryId === '0' || !allCategoriesMap[item.categoryId])">
<v-icon class="me-1" :icon="mdiAlertOutline"/>
<span>{{ item.originalCategoryName }}</span>
</div>
</div>
<div style="width: 260px" v-if="editingTransaction === item && item.type === TransactionType.Expense">
<two-column-select density="compact" variant="plain"
primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="!!disabled || !hasAvailableExpenseCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(item.categoryId, allCategories[CategoryType.Expense])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(item.categoryId, allCategories[CategoryType.Expense])"
:placeholder="tt('Category')"
:items="allCategories[CategoryType.Expense]"
v-model="item.categoryId">
</two-column-select>
</div>
<div style="width: 260px" v-if="editingTransaction === item && item.type === TransactionType.Income">
<two-column-select density="compact" variant="plain"
primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="!!disabled || !hasAvailableIncomeCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(item.categoryId, allCategories[CategoryType.Income])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(item.categoryId, allCategories[CategoryType.Income])"
:placeholder="tt('Category')"
:items="allCategories[CategoryType.Income]"
v-model="item.categoryId">
</two-column-select>
</div>
<div style="width: 260px" v-if="editingTransaction === item && item.type === TransactionType.Transfer">
<two-column-select density="compact" variant="plain"
primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="!!disabled || !hasAvailableTransferCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(item.categoryId, allCategories[CategoryType.Transfer])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(item.categoryId, allCategories[CategoryType.Transfer])"
:placeholder="tt('Category')"
:items="allCategories[CategoryType.Transfer]"
v-model="item.categoryId">
</two-column-select>
</div>
</template>
<template #item.sourceAmount="{ item }">
<span>{{ getTransactionDisplayAmount(item) }}</span>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer && item.sourceAccountId !== item.destinationAccountId"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.sourceAccountId !== item.destinationAccountId">{{ getTransactionDisplayDestinationAmount(item) }}</span>
</template>
<template #item.actualSourceAccountName="{ item }">
<div class="d-flex align-center" v-if="editingTransaction !== item">
<span v-if="item.sourceAccountId && item.sourceAccountId !== '0' && allAccountsMap[item.sourceAccountId]">{{ allAccountsMap[item.sourceAccountId].name }}</span>
<div class="text-error font-italic" v-else>
<v-icon class="me-1" :icon="mdiAlertOutline"/>
<span>{{ item.originalSourceAccountName }}</span>
</div>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.destinationAccountId && item.destinationAccountId !== '0' && allAccountsMap[item.destinationAccountId]">{{allAccountsMap[item.destinationAccountId].name }}</span>
<div class="text-error font-italic" v-else-if="item.type === TransactionType.Transfer && (!item.destinationAccountId || item.destinationAccountId === '0' || !allAccountsMap[item.destinationAccountId])">
<v-icon class="me-1" :icon="mdiAlertOutline"/>
<span>{{ item.originalDestinationAccountName }}</span>
</div>
</div>
<div class="d-flex align-center" style="width: 200px" v-if="editingTransaction === item">
<two-column-select density="compact" variant="plain"
primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="!!disabled || !allVisibleAccounts.length"
:enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')"
:custom-selection-primary-text="getSourceAccountDisplayName(item)"
:placeholder="getSourceAccountTitle(item)"
:items="allVisibleCategorizedAccounts"
v-model="item.sourceAccountId">
</two-column-select>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer"></v-icon>
<two-column-select density="compact" variant="plain"
primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="!!disabled || !allVisibleAccounts.length"
:enable-filter="true" :filter-placeholder="tt('Find account')" :filter-no-items-text="tt('No available account')"
:custom-selection-primary-text="getDestinationAccountDisplayName(item)"
:placeholder="tt('Destination Account')"
:items="allVisibleCategorizedAccounts"
v-model="item.destinationAccountId"
v-if="item.type === TransactionType.Transfer">
</two-column-select>
</div>
</template>
<template #item.geoLocation="{ item }">
<span v-if="item.geoLocation">{{ `(${formatCoordinate(item.geoLocation, coordinateDisplayType)})` }}</span>
<span v-else-if="!item.geoLocation">{{ tt('None') }}</span>
</template>
<template #item.tagIds="{ item }">
<div v-if="editingTransaction !== item">
<v-chip class="transaction-tag" size="small"
:class="{ 'font-italic': !tagId || tagId === '0' || !allTagsMap[tagId] }"
:prepend-icon="tagId && tagId !== '0' && allTagsMap[tagId] ? mdiPound : mdiAlertOutline"
:color="tagId && tagId !== '0' && allTagsMap[tagId] ? 'default' : 'error'"
:text="tagId && tagId !== '0' && allTagsMap[tagId] ? allTagsMap[tagId].name : item.originalTagNames[index]"
:key="tagId"
v-for="(tagId, index) in item.tagIds"/>
<v-chip class="transaction-tag" size="small"
:text="tt('None')"
v-if="!item.tagIds || !item.tagIds.length"/>
</div>
<div style="width: 200px" v-if="editingTransaction === item">
<v-autocomplete
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
density="compact" variant="plain"
:disabled="!!disabled"
:placeholder="tt('None')"
:items="allTags"
:no-data-text="tt('No available tag')"
v-model="editingTags"
>
<template #chip="{ props, index }">
<v-chip :class="{ 'font-italic': !isTagValid(editingTags, index) }"
:prepend-icon="isTagValid(editingTags, index) ? mdiPound : mdiAlertOutline"
:color="isTagValid(editingTags, index) ? 'default' : 'error'"
:text="isTagValid(editingTags, index) ? allTagsMap[editingTags[index]].name : item.originalTagNames[index]"
v-bind="props"/>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="!item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2"
v-if="importTransactions && importTransactions.length > 10">
<span :class="{ 'text-error': selectedInvalidTransactionCount > 0 }">
{{ tt('format.misc.selectedCount', { count: getDisplayCount(selectedImportTransactionCount), totalCount: getDisplayCount(importTransactions.length) }) }}
</span>
<v-spacer/>
<span>{{ tt('Transactions Per Page') }}</span>
<v-select class="ms-2" density="compact" max-width="100"
item-title="name"
item-value="value"
:disabled="!!disabled"
:items="importTransactionsTablePageOptions"
v-model="countPerPage"
/>
<pagination-buttons density="compact"
:disabled="!!disabled"
:totalPageCount="totalPageCount"
v-model="currentPage"></pagination-buttons>
</div>
</template>
</v-data-table>
<v-dialog width="640" v-model="showCustomDescriptionDialog">
<v-card class="pa-2 pa-sm-4 pa-md-4">
<template #title>
<div class="d-flex align-center justify-center">
<h4 class="text-h4">{{ tt('Filter Description') }}</h4>
</div>
</template>
<v-card-text class="mb-md-4 w-100 d-flex justify-center">
<v-text-field
type="text"
persistent-placeholder
:label="tt('Description')"
:placeholder="tt('Description')"
v-model="currentDescriptionFilterValue"
/>
</v-card-text>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center gap-4">
<v-btn :disabled="!currentDescriptionFilterValue" @click="showCustomDescriptionDialog = false; filters.description = currentDescriptionFilterValue">{{ tt('OK') }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="showCustomDescriptionDialog = false; currentDescriptionFilterValue = ''">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<date-range-selection-dialog :title="tt('Custom Date Range')"
:min-time="filters.minDatetime"
:max-time="filters.maxDatetime"
v-model:show="showCustomDateRangeDialog"
@dateRange:change="changeCustomDateFilter"
@error="onShowDateRangeError" />
<batch-replace-dialog ref="batchReplaceDialog" />
<batch-replace-all-types-dialog ref="batchReplaceAllTypesDialog" />
<batch-create-dialog ref="batchCreateDialog" />
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import BatchReplaceDialog, { type BatchReplaceDialogDataType } from '../dialogs/BatchReplaceDialog.vue';
import BatchReplaceAllTypesDialog from '../dialogs/BatchReplaceAllTypesDialog.vue';
import BatchCreateDialog, { type BatchCreateDialogDataType } from '../dialogs/BatchCreateDialog.vue';
import { ref, computed, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts';
import { useAccountsStore } from '@/stores/account.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { type NameValue, type NameNumeralValue, itemAndIndex, reversed, keys } from '@/core/base.ts';
import { type NumeralSystem } from '@/core/numeral.ts';
import { CategoryType } from '@/core/category.ts';
import { TransactionType } from '@/core/transaction.ts';
import { Account, type CategorizedAccountWithDisplayBalance } from '@/models/account.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
import type { TransactionTag } from '@/models/transaction_tag.ts';
import { ImportTransaction } from '@/models/imported_transaction.ts';
import {
isString,
isNumber,
objectFieldToArrayItem
} from '@/lib/common.ts';
import {
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes
} from '@/lib/datetime.ts';
import { formatCoordinate } from '@/lib/coordinate.ts';
import {
getAccountMapByName
} from '@/lib/account.ts';
import {
transactionTypeToCategoryType,
getSecondaryTransactionMapByName,
getTransactionPrimaryCategoryName,
getTransactionSecondaryCategoryName
} from '@/lib/category.ts';
import {
mdiCheck,
mdiArrowRight,
mdiSelectAll,
mdiSelect,
mdiSelectInverse,
mdiPencilOutline,
mdiAlertOutline,
mdiPound,
mdiFindReplace,
mdiShapePlusOutline,
mdiTransfer
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
type BatchReplaceDialogType = InstanceType<typeof BatchReplaceDialog>;
type BatchReplaceAllTypesDialogType = InstanceType<typeof BatchReplaceAllTypesDialog>;
type BatchCreateDialogType = InstanceType<typeof BatchCreateDialog>;
interface ImportTransactionCheckDataFilter {
minDatetime: number | null; // minDatetime or maxDatetime is null for 'All Date Range', all are not null for 'Custom Date Range'
maxDatetime: number | null;
transactionType: TransactionType | null; // null for 'All Transaction Type'
category: string | null | undefined; // null for 'All Category', undefined for 'Invalid Category'
account: string | null | undefined; // null for 'All Account', undefined for 'Invalid Account'
tag: string | null | undefined; // null for 'All Tag', undefined for 'Invalid Tag'
description: string | null; // null for 'All Description'
}
interface ImportTransactionCheckDataMenuGroup {
title: string;
items: ImportTransactionCheckDataMenu[];
}
interface ImportTransactionCheckDataMenu {
prependIcon?: string;
title: string;
subTitle?: string;
appendIcon?: string;
disabled?: boolean;
divider?: boolean;
onClick: () => void;
}
const props = defineProps<{
importTransactions?: ImportTransaction[]
disabled?: boolean;
}>();
const {
tt,
getCurrentNumeralSystemType,
formatUnixTimeToLongDateTime,
formatAmountToLocalizedNumeralsWithCurrency,
getCategorizedAccountsWithDisplayBalance
} = useI18n();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const accountsStore = useAccountsStore();
const transactionCategoriesStore = useTransactionCategoriesStore();
const transactionTagsStore = useTransactionTagsStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const batchReplaceDialog = useTemplateRef<BatchReplaceDialogType>('batchReplaceDialog');
const batchReplaceAllTypesDialog = useTemplateRef<BatchReplaceAllTypesDialogType>('batchReplaceAllTypesDialog');
const batchCreateDialog = useTemplateRef<BatchCreateDialogType>('batchCreateDialog');
const editingTransaction = ref<ImportTransaction | null>(null);
const editingTags = ref<string[]>([]);
const filters = ref<ImportTransactionCheckDataFilter>({
minDatetime: null,
maxDatetime: null,
transactionType: null,
category: null,
account: null,
tag: null,
description: null
});
const currentPage = ref<number>(1);
const countPerPage = ref<number>(10);
const showCustomDateRangeDialog = ref<boolean>(false);
const showCustomDescriptionDialog = ref<boolean>(false);
const currentDescriptionFilterValue = ref<string | null>(null);
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const showAccountBalance = computed<boolean>(() => settingsStore.appSettings.showAccountBalance);
const currentTimezoneOffsetMinutes = computed<number>(() => getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone));
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const coordinateDisplayType = computed<number>(() => userStore.currentUserCoordinateDisplayType);
const allAccounts = computed<Account[]>(() => accountsStore.allPlainAccounts);
const allVisibleAccounts = computed<Account[]>(() => accountsStore.allVisiblePlainAccounts);
const allVisibleCategorizedAccounts = computed<CategorizedAccountWithDisplayBalance[]>(() => getCategorizedAccountsWithDisplayBalance(allVisibleAccounts.value, showAccountBalance.value));
const allAccountsMap = computed<Record<string, Account>>(() => accountsStore.allAccountsMap);
const allAccountsMapByName = computed<Record<string, Account>>(() => getAccountMapByName(accountsStore.allAccounts));
const allCategories = computed<Record<number, TransactionCategory[]>>(() => transactionCategoriesStore.allTransactionCategories);
const allCategoriesMap = computed<Record<string, TransactionCategory>>(() => transactionCategoriesStore.allTransactionCategoriesMap);
const allTags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTags);
const allTagsMap = computed<Record<string, TransactionTag>>(() => transactionTagsStore.allTransactionTagsMap);
const hasAvailableExpenseCategories = computed<boolean>(() => transactionCategoriesStore.hasAvailableExpenseCategories);
const hasAvailableIncomeCategories = computed<boolean>(() => transactionCategoriesStore.hasAvailableIncomeCategories);
const hasAvailableTransferCategories = computed<boolean>(() => transactionCategoriesStore.hasAvailableTransferCategories);
const isEditing = computed<boolean>(() => !!editingTransaction.value);
const canImport = computed<boolean>(() => selectedImportTransactionCount.value > 0 && selectedInvalidTransactionCount.value < 1);
const filterMenus = computed<ImportTransactionCheckDataMenuGroup[]>(() => [
{
title: tt('Date Range'),
items: [
{
title: tt('All'),
appendIcon: filters.value.minDatetime === null || filters.value.maxDatetime === null ? mdiCheck : undefined,
onClick: () => {
filters.value.minDatetime = null;
filters.value.maxDatetime = null;
}
},
{
title: tt('Custom'),
subTitle: displayFilterCustomDateRange.value,
appendIcon: filters.value.minDatetime !== null && filters.value.maxDatetime !== null ? mdiCheck : undefined,
onClick: () => showCustomDateRangeDialog.value = true
}
]
},
{
title: tt('Type'),
items: [
{
title: tt('All'),
appendIcon: filters.value.transactionType === null ? mdiCheck : undefined,
onClick: () => filters.value.transactionType = null
},
{
title: tt('Income'),
appendIcon: filters.value.transactionType === TransactionType.Income ? mdiCheck : undefined,
onClick: () => filters.value.transactionType = TransactionType.Income
},
{
title: tt('Expense'),
appendIcon: filters.value.transactionType === TransactionType.Expense ? mdiCheck : undefined,
onClick: () => filters.value.transactionType = TransactionType.Expense
},
{
title: tt('Transfer'),
appendIcon: filters.value.transactionType === TransactionType.Transfer ? mdiCheck : undefined,
onClick: () => filters.value.transactionType = TransactionType.Transfer
}
]
},
{
title: tt('Category'),
items: [
{
title: tt('All'),
appendIcon: filters.value.category === null ? mdiCheck : undefined,
onClick: () => filters.value.category = null
},
{
title: tt('Invalid Category'),
appendIcon: filters.value.category === undefined ? mdiCheck : undefined,
onClick: () => filters.value.category = undefined
},
{
title: tt('None'),
appendIcon: filters.value.category === '' ? mdiCheck : undefined,
onClick: () => filters.value.category = ''
},
...allUsedCategoryNames.value.map(name => ({
title: name,
appendIcon: filters.value.category === name ? mdiCheck : undefined,
onClick: () => filters.value.category = name
}))
]
},
{
title: tt('Account'),
items: [
{
title: tt('All'),
appendIcon: filters.value.account === null ? mdiCheck : undefined,
onClick: () => filters.value.account = null
},
{
title: tt('Invalid Account'),
appendIcon: filters.value.account === undefined ? mdiCheck : undefined,
onClick: () => filters.value.account = undefined
},
{
title: tt('None'),
appendIcon: filters.value.account === '' ? mdiCheck : undefined,
onClick: () => filters.value.account = ''
},
...allUsedAccountNames.value.map(name => ({
title: name,
appendIcon: filters.value.account === name ? mdiCheck : undefined,
onClick: () => filters.value.account = name
}))
]
},
{
title: tt('Tags'),
items: [
{
title: tt('All'),
appendIcon: filters.value.tag === null ? mdiCheck : undefined,
onClick: () => filters.value.tag = null
},
{
title: tt('Invalid Tag'),
appendIcon: filters.value.tag === undefined ? mdiCheck : undefined,
onClick: () => filters.value.tag = undefined
},
{
title: tt('None'),
appendIcon: filters.value.tag === '' ? mdiCheck : undefined,
onClick: () => filters.value.tag = ''
},
...allUsedTagNames.value.map(name => ({
title: name,
appendIcon: filters.value.tag === name ? mdiCheck : undefined,
onClick: () => filters.value.tag = name
}))
]
},
{
title: tt('Description'),
items: [
{
title: tt('All'),
appendIcon: filters.value.description === null ? mdiCheck : undefined,
onClick: () => filters.value.description = null
},
{
title: tt('None'),
appendIcon: filters.value.description === '' ? mdiCheck : undefined,
onClick: () => filters.value.description = ''
},
{
title: tt('Custom'),
subTitle: filters.value.description !== null ? filters.value.description : undefined,
appendIcon: filters.value.description !== null && filters.value.description !== '' ? mdiCheck : undefined,
onClick: () => {
currentDescriptionFilterValue.value = filters.value.description || '';
showCustomDescriptionDialog.value = true;
}
}
]
}
]);
const toolMenus = computed<ImportTransactionCheckDataMenu[]>(() => [
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Expense Categories'),
disabled: isEditing.value || selectedExpenseTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('expenseCategory')
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Income Categories'),
disabled: isEditing.value || selectedIncomeTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('incomeCategory')
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Transfer Categories'),
disabled: isEditing.value || selectedTransferTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('transferCategory')
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Accounts'),
disabled: isEditing.value || selectedImportTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('account')
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Destination Accounts'),
disabled: isEditing.value || selectedTransferTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('destinationAccount')
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Selected Transaction Tags'),
disabled: isEditing.value || selectedImportTransactionCount.value < 1,
onClick: () => showBatchReplaceDialog('tag', allOriginalTransactionTagNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Replace Invalid Expense Categories'),
disabled: isEditing.value || !allInvalidExpenseCategoryNames.value || allInvalidExpenseCategoryNames.value.length < 1,
divider: true,
onClick: () => showReplaceInvalidItemDialog('expenseCategory', allInvalidExpenseCategoryNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Replace Invalid Income Categories'),
disabled: isEditing.value || !allInvalidIncomeCategoryNames.value || allInvalidIncomeCategoryNames.value.length < 1,
onClick: () => showReplaceInvalidItemDialog('incomeCategory', allInvalidIncomeCategoryNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Replace Invalid Transfer Categories'),
disabled: isEditing.value || !allInvalidTransferCategoryNames.value || allInvalidTransferCategoryNames.value.length < 1,
onClick: () => showReplaceInvalidItemDialog('transferCategory', allInvalidTransferCategoryNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Replace Invalid Accounts'),
disabled: isEditing.value || !allInvalidAccountNames.value || allInvalidAccountNames.value.length < 1,
onClick: () => showReplaceInvalidItemDialog('account', allInvalidAccountNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Replace Invalid Transaction Tags'),
disabled: isEditing.value || !allInvalidTransactionTagNames.value || allInvalidTransactionTagNames.value.length < 1,
onClick: () => showReplaceInvalidItemDialog('tag', allInvalidTransactionTagNames.value)
},
{
prependIcon: mdiFindReplace,
title: tt('Batch Replace Categories / Accounts / Tags'),
disabled: isEditing.value,
divider: true,
onClick: showReplaceAllTypesDialog
},
{
prependIcon: mdiShapePlusOutline,
title: tt('Create Nonexistent Expense Categories'),
disabled: isEditing.value || !allInvalidExpenseCategoryNames.value || allInvalidExpenseCategoryNames.value.length < 1,
divider: true,
onClick: () => showBatchCreateInvalidItemDialog('expenseCategory', allInvalidExpenseCategoryNames.value)
},
{
prependIcon: mdiShapePlusOutline,
title: tt('Create Nonexistent Income Categories'),
disabled: isEditing.value || !allInvalidIncomeCategoryNames.value || allInvalidIncomeCategoryNames.value.length < 1,
onClick: () => showBatchCreateInvalidItemDialog('incomeCategory', allInvalidIncomeCategoryNames.value)
},
{
prependIcon: mdiShapePlusOutline,
title: tt('Create Nonexistent Transfer Categories'),
disabled: isEditing.value || !allInvalidTransferCategoryNames.value || allInvalidTransferCategoryNames.value.length < 1,
onClick: () => showBatchCreateInvalidItemDialog('transferCategory', allInvalidTransferCategoryNames.value)
},
{
prependIcon: mdiShapePlusOutline,
title: tt('Create Nonexistent Transaction Tags'),
disabled: isEditing.value || !allInvalidTransactionTagNames.value || allInvalidTransactionTagNames.value.length < 1,
onClick: () => showBatchCreateInvalidItemDialog('tag', allInvalidTransactionTagNames.value)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Expense Transaction to Income Transaction'),
disabled: isEditing.value || selectedExpenseTransactionCount.value < 1,
divider: true,
onClick: () => convertTransactionType(TransactionType.Expense, TransactionType.Income)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Expense Transaction to Transfer Transaction'),
disabled: isEditing.value || selectedExpenseTransactionCount.value < 1,
onClick: () => convertTransactionType(TransactionType.Expense, TransactionType.Transfer)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Income Transaction to Expense Transaction'),
disabled: isEditing.value || selectedIncomeTransactionCount.value < 1,
onClick: () => convertTransactionType(TransactionType.Income, TransactionType.Expense)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Income Transaction to Transfer Transaction'),
disabled: isEditing.value || selectedIncomeTransactionCount.value < 1,
onClick: () => convertTransactionType(TransactionType.Income, TransactionType.Transfer)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Transfer Transaction to Expense Transaction'),
disabled: isEditing.value || selectedTransferTransactionCount.value < 1,
onClick: () => convertTransactionType(TransactionType.Transfer, TransactionType.Expense)
},
{
prependIcon: mdiTransfer,
title: tt('Batch Convert Transfer Transaction to Income Transaction'),
disabled: isEditing.value || selectedTransferTransactionCount.value < 1,
onClick: () => convertTransactionType(TransactionType.Transfer, TransactionType.Income)
}
]);
const importTransactionsTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !props.importTransactions || props.importTransactions.length <= 10) {
return undefined;
} else {
return 400;
}
});
const importTransactionHeaders = computed<object[]>(() => {
return [
{ value: 'valid', sortable: true, nowrap: true, width: 35 },
{ value: 'time', title: tt('Transaction Time'), sortable: true, nowrap: true, maxWidth: 280 },
{ value: 'type', title: tt('Type'), sortable: true, nowrap: true, maxWidth: 140 },
{ value: 'actualCategoryName', title: tt('Category'), sortable: true, nowrap: true },
{ value: 'sourceAmount', title: tt('Amount'), sortable: true, nowrap: true },
{ value: 'actualSourceAccountName', title: tt('Account'), sortable: true, nowrap: true },
{ value: 'geoLocation', title: tt('Geographic Location'), sortable: true, nowrap: true },
{ value: 'tagIds', title: tt('Tags'), sortable: true, nowrap: true },
{ value: 'comment', title: tt('Description'), sortable: true, nowrap: true },
];
});
const importTransactionsTablePageOptions = computed<NameNumeralValue[]>(() => getTablePageOptions(props.importTransactions?.length));
const totalPageCount = computed<number>(() => {
if (!props.importTransactions || props.importTransactions.length < 1) {
return 1;
}
let count = 0;
for (const importTransaction of props.importTransactions) {
if (isTransactionDisplayed(importTransaction)) {
count++;
}
}
return Math.ceil(count / countPerPage.value);
});
const currentPageTransactions = computed<ImportTransaction[]>(() => {
const ret: ImportTransaction[] = [];
if (!props.importTransactions || props.importTransactions.length < 1) {
return ret;
}
const previousCount = Math.max(0, (currentPage.value - 1) * countPerPage.value);
let count = 0;
for (const importTransaction of props.importTransactions) {
if (ret.length >= countPerPage.value) {
break;
}
if (isTransactionDisplayed(importTransaction)) {
if (count >= previousCount) {
ret.push(importTransaction);
}
count++;
}
}
return ret;
});
const selectedImportTransactionCount = computed<number>(() => {
let count = 0;
if (!props.importTransactions || props.importTransactions.length < 1) {
return count;
}
for (const importTransaction of props.importTransactions) {
if (importTransaction.selected) {
count++;
}
}
return count;
});
const selectedExpenseTransactionCount = computed<number>(() => {
let count = 0;
if (!props.importTransactions || props.importTransactions.length < 1) {
return count;
}
for (const importTransaction of props.importTransactions) {
if (importTransaction.selected && importTransaction.type === TransactionType.Expense) {
count++;
}
}
return count;
});
const selectedIncomeTransactionCount = computed<number>(() => {
let count = 0;
if (!props.importTransactions || props.importTransactions.length < 1) {
return count;
}
for (const importTransaction of props.importTransactions) {
if (importTransaction.selected && importTransaction.type === TransactionType.Income) {
count++;
}
}
return count;
});
const selectedTransferTransactionCount = computed<number>(() => {
let count = 0;
if (!props.importTransactions || props.importTransactions.length < 1) {
return count;
}
for (const importTransaction of props.importTransactions) {
if (importTransaction.selected && importTransaction.type === TransactionType.Transfer) {
count++;
}
}
return count;
});
const selectedInvalidTransactionCount = computed<number>(() => {
let count = 0;
if (!props.importTransactions || props.importTransactions.length < 1) {
return count;
}
for (const importTransaction of props.importTransactions) {
if (!importTransaction.valid && importTransaction.selected) {
count++;
}
}
return count;
});
const anyButNotAllTransactionSelected = computed<boolean>(() => !!props.importTransactions && selectedImportTransactionCount.value > 0 && selectedImportTransactionCount.value !== props.importTransactions.length);
const allTransactionSelected = computed<boolean>(() => !!props.importTransactions && selectedImportTransactionCount.value === props.importTransactions.length);
const allUsedCategoryNames = computed<string[]>(() => {
if (!props.importTransactions || props.importTransactions.length < 1) {
return [];
}
const categoryNames: Record<string, boolean> = {};
for (const transaction of props.importTransactions) {
if (transaction.actualCategoryName && transaction.actualCategoryName !== '') {
categoryNames[transaction.actualCategoryName] = true;
}
}
return objectFieldToArrayItem(categoryNames);
});
const allUsedAccountNames = computed<string[]>(() => {
if (!props.importTransactions || props.importTransactions.length < 1) {
return [];
}
const accountNames: Record<string, boolean> = {};
for (const transaction of props.importTransactions) {
if (transaction.actualSourceAccountName && transaction.actualSourceAccountName !== '') {
accountNames[transaction.actualSourceAccountName] = true;
}
if (transaction.actualDestinationAccountName && transaction.actualDestinationAccountName !== '') {
accountNames[transaction.actualDestinationAccountName] = true;
}
}
return objectFieldToArrayItem(accountNames);
});
const allUsedTagNames = computed<string[]>(() => {
if (!props.importTransactions || props.importTransactions.length < 1) {
return [];
}
const tagNames: Record<string, boolean> = {};
for (const transaction of props.importTransactions) {
if (!transaction.tagIds || !transaction.originalTagNames) {
continue;
}
for (const [tagId, tagIndex] of itemAndIndex(transaction.tagIds)) {
const originalTagName = transaction.originalTagNames[tagIndex] as string | undefined;
if (tagId && tagId !== '0' && allTagsMap.value[tagId] && allTagsMap.value[tagId].name) {
tagNames[allTagsMap.value[tagId].name] = true;
} else if (originalTagName) {
tagNames[originalTagName] = true;
}
}
}
return objectFieldToArrayItem(tagNames);
});
const allInvalidExpenseCategoryNames = computed<NameValue[]>(() => getCurrentInvalidCategoryNames(TransactionType.Expense));
const allInvalidIncomeCategoryNames = computed<NameValue[]>(() => getCurrentInvalidCategoryNames(TransactionType.Income));
const allInvalidTransferCategoryNames = computed<NameValue[]>(() => getCurrentInvalidCategoryNames(TransactionType.Transfer));
const allInvalidAccountNames = computed<NameValue[]>(() => getCurrentInvalidAccountNames());
const allInvalidTransactionTagNames = computed<NameValue[]>(() => getCurrentInvalidTagNames());
const allOriginalTransactionTagNames = computed<NameValue[]>(() => getAllOriginalTagNames());
const displayFilterCustomDateRange = computed<string>(() => {
if (filters.value.minDatetime === null || filters.value.maxDatetime === null) {
return '';
}
const minDisplayTime = formatUnixTimeToLongDateTime(filters.value.minDatetime);
const maxDisplayTime = formatUnixTimeToLongDateTime(filters.value.maxDatetime);
return `${minDisplayTime} - ${maxDisplayTime}`
});
function getDisplayCount(count: number): string {
return numeralSystem.value.formatNumber(count);
}
function getTablePageOptions(linesCount?: number): NameNumeralValue[] {
const pageOptions: NameNumeralValue[] = [];
if (!linesCount || linesCount < 1) {
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
for (const count of [ 5, 10, 15, 20, 25, 30, 50 ]) {
if (linesCount < count) {
break;
}
pageOptions.push({ value: count, name: getDisplayCount(count) });
}
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
function isTransactionDisplayed(transaction: ImportTransaction): boolean {
if (isNumber(filters.value.minDatetime) && isNumber(filters.value.maxDatetime) && (transaction.time < filters.value.minDatetime || transaction.time > filters.value.maxDatetime)) {
return false;
}
if (isNumber(filters.value.transactionType) && transaction.type !== filters.value.transactionType) {
return false;
}
if (isString(filters.value.category)) {
if (filters.value.category === '' && transaction.actualCategoryName !== '') {
return false;
} else if (filters.value.category !== '' && transaction.actualCategoryName !== filters.value.category) {
return false;
}
} else if (filters.value.category === undefined) {
if (transaction.type !== TransactionType.ModifyBalance && transaction.categoryId && transaction.categoryId !== '0') {
return false;
}
}
if (isString(filters.value.account)) {
if (filters.value.account === '' && (transaction.actualSourceAccountName !== '' || transaction.actualDestinationAccountName !== '')) {
return false;
} else if (filters.value.account !== '' && transaction.actualSourceAccountName !== filters.value.account && transaction.actualDestinationAccountName !== filters.value.account) {
return false;
}
} else if (filters.value.account === undefined) {
if (transaction.type !== TransactionType.Transfer && transaction.sourceAccountId && transaction.sourceAccountId !== '0') {
return false;
} else if (transaction.type === TransactionType.Transfer && transaction.sourceAccountId && transaction.sourceAccountId !== '0' && transaction.destinationAccountId && transaction.destinationAccountId !== '0') {
return false;
}
}
if (isString(filters.value.tag)) {
if (filters.value.tag === '' && transaction.tagIds && transaction.tagIds.length) {
return false;
} else if (filters.value.tag !== '') {
let hasTagName = false;
if (transaction.tagIds && transaction.tagIds.length) {
for (const [tagId, tagIndex] of itemAndIndex(transaction.tagIds)) {
let tagName: string = transaction.originalTagNames ? (transaction.originalTagNames[tagIndex] ?? '') : '';
if (tagId && tagId !== '0' && allTagsMap.value[tagId] && allTagsMap.value[tagId].name) {
tagName = allTagsMap.value[tagId].name;
}
if (tagName === filters.value.tag) {
hasTagName = true;
break;
}
}
}
if (!hasTagName) {
return false;
}
}
} else if (filters.value.tag === undefined) {
if (transaction.tagIds && transaction.tagIds.length) {
let hasInvalidTag = false;
for (const tagId of transaction.tagIds) {
if (!tagId || tagId === '0') {
hasInvalidTag = true;
break;
}
}
if (!hasInvalidTag) {
return false;
}
} else {
return false;
}
}
if (isString(filters.value.description)) {
if (filters.value.description === '' && transaction.comment !== '') {
return false;
} else if (filters.value.description !== '' && transaction.comment.indexOf(filters.value.description) < 0) {
return false;
}
}
return true;
}
function isTagValid(tagIds: string[], tagIndex: number): boolean {
if (!tagIds || !tagIds[tagIndex]) {
return false;
}
if (tagIds[tagIndex] === '0') {
return false;
}
const tagId = tagIds[tagIndex];
return !!allTagsMap.value[tagId];
}
function getDisplayDateTime(transaction: ImportTransaction): string {
return formatUnixTimeToLongDateTime(transaction.time, transaction.utcOffset, currentTimezoneOffsetMinutes.value);
}
function getDisplayTimezone(transaction: ImportTransaction): string {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
}
function getDisplayCurrency(value: number, currencyCode: string): string {
return formatAmountToLocalizedNumeralsWithCurrency(value, currencyCode);
}
function getTransactionDisplayAmount(transaction: ImportTransaction): string {
let currency = transaction.originalSourceAccountCurrency || defaultCurrency.value;
if (transaction.sourceAccountId && transaction.sourceAccountId !== '0' && allAccountsMap.value[transaction.sourceAccountId]) {
currency = allAccountsMap.value[transaction.sourceAccountId].currency;
}
return getDisplayCurrency(transaction.sourceAmount, currency);
}
function getTransactionDisplayDestinationAmount(transaction: ImportTransaction): string {
if (transaction.type !== TransactionType.Transfer) {
return '-';
}
let currency = transaction.originalDestinationAccountCurrency || defaultCurrency.value;
if (transaction.destinationAccountId && transaction.destinationAccountId !== '0' && allAccountsMap.value[transaction.destinationAccountId]) {
currency = allAccountsMap.value[transaction.destinationAccountId].currency;
}
return getDisplayCurrency(transaction.destinationAmount, currency);
}
function getSourceAccountTitle(transaction: ImportTransaction): string {
if (transaction.type === TransactionType.Expense || transaction.type === TransactionType.Income) {
return tt('Account');
} else if (transaction.type === TransactionType.Transfer) {
return tt('Source Account');
} else {
return tt('Account');
}
}
function getSourceAccountDisplayName(transaction: ImportTransaction): string {
if (transaction.sourceAccountId) {
return Account.findAccountNameById(allAccounts.value, transaction.sourceAccountId) || '';
} else {
return tt('None');
}
}
function getDestinationAccountDisplayName(transaction: ImportTransaction): string {
if (transaction.destinationAccountId) {
return Account.findAccountNameById(allAccounts.value, transaction.destinationAccountId) || '';
} else {
return tt('None');
}
}
function getCurrentInvalidCategoryNames(transactionType: TransactionType): NameValue[] {
const invalidCategoryNames: Record<string, boolean> = {};
const invalidCategories: NameValue[] = [];
if (!props.importTransactions || props.importTransactions.length < 1) {
return invalidCategories;
}
for (const importTransaction of props.importTransactions) {
const categoryId = importTransaction.categoryId;
if (importTransaction.type === transactionType && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
invalidCategoryNames[importTransaction.originalCategoryName] = true;
}
}
for (const name of keys(invalidCategoryNames)) {
invalidCategories.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidCategories;
}
function getCurrentInvalidAccountNames(): NameValue[] {
const invalidAccountNames: Record<string, boolean> = {};
const invalidAccounts: NameValue[] = [];
if (!props.importTransactions || props.importTransactions.length < 1) {
return invalidAccounts;
}
for (const importTransaction of props.importTransactions) {
const sourceAccountId = importTransaction.sourceAccountId;
const destinationAccountId = importTransaction.destinationAccountId;
if (!sourceAccountId || sourceAccountId === '0' || !allAccountsMap.value[sourceAccountId]) {
invalidAccountNames[importTransaction.originalSourceAccountName] = true;
}
if (importTransaction.type === TransactionType.Transfer && isString(importTransaction.originalDestinationAccountName) && (!destinationAccountId || destinationAccountId === '0' || !allAccountsMap.value[destinationAccountId])) {
invalidAccountNames[importTransaction.originalDestinationAccountName] = true;
}
}
for (const name of keys(invalidAccountNames)) {
invalidAccounts.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidAccounts;
}
function getCurrentInvalidTagNames(): NameValue[] {
const invalidTagNames: Record<string, boolean> = {};
const invalidTags: NameValue[] = [];
if (!props.importTransactions || props.importTransactions.length < 1) {
return invalidTags;
}
for (const importTransaction of props.importTransactions) {
if (!importTransaction.tagIds || !importTransaction.originalTagNames) {
continue;
}
for (const [tagId, tagIndex] of itemAndIndex(importTransaction.tagIds)) {
const originalTagName = importTransaction.originalTagNames[tagIndex] as string | undefined;
if (!originalTagName) {
continue;
}
if (!tagId || tagId === '0' || !allTagsMap.value[tagId]) {
invalidTagNames[originalTagName] = true;
}
}
}
for (const name of keys(invalidTagNames)) {
invalidTags.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidTags;
}
function getAllOriginalTagNames(): NameValue[] {
const allOriginalTagNames: Record<string, boolean> = {};
const allOriginalTags: NameValue[] = [];
if (!props.importTransactions || props.importTransactions.length < 1) {
return allOriginalTags;
}
for (const importTransaction of props.importTransactions) {
if (!importTransaction.originalTagNames) {
continue;
}
for (const tagName of importTransaction.originalTagNames) {
allOriginalTagNames[tagName] = true;
}
}
for (const name of keys(allOriginalTagNames)) {
allOriginalTags.push({
name: name || tt('(Empty)'),
value: name
});
}
return allOriginalTags;
}
function importTransactionsFilter(value: string, query: string, item?: { value: unknown, raw: ImportTransaction }): boolean {
if (!item || !item.raw) {
return false;
}
return isTransactionDisplayed(item.raw);
}
function selectAllValid(): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
for (const importTransaction of props.importTransactions) {
if (importTransaction.valid && isTransactionDisplayed(importTransaction)) {
importTransaction.selected = true;
}
}
}
function selectAllInvalid(): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
for (const importTransaction of props.importTransactions) {
if (!importTransaction.valid && isTransactionDisplayed(importTransaction)) {
importTransaction.selected = true;
}
}
}
function selectAll(): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
for (const importTransaction of props.importTransactions) {
if (isTransactionDisplayed(importTransaction)) {
importTransaction.selected = true;
}
}
}
function selectNone(): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
for (const importTransaction of props.importTransactions) {
if (isTransactionDisplayed(importTransaction)) {
importTransaction.selected = false;
}
}
}
function selectInvert(): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
for (const importTransaction of props.importTransactions) {
if (isTransactionDisplayed(importTransaction)) {
importTransaction.selected = !importTransaction.selected;
}
}
}
function selectAllInThisPage(): void {
for (const importTransaction of currentPageTransactions.value) {
importTransaction.selected = true;
}
}
function selectNoneInThisPage(): void {
for (const importTransaction of currentPageTransactions.value) {
importTransaction.selected = false;
}
}
function selectInvertInThisPage(): void {
for (const importTransaction of currentPageTransactions.value) {
importTransaction.selected = !importTransaction.selected;
}
}
function editTransaction(transaction: ImportTransaction): void {
if (editingTransaction.value) {
editingTransaction.value.tagIds = editingTags.value;
updateTransactionData(editingTransaction.value);
}
if (editingTransaction.value === transaction) {
editingTags.value = [];
editingTransaction.value = null;
} else {
editingTransaction.value = transaction;
editingTags.value = editingTransaction.value.tagIds;
}
}
function updateTransactionData(transaction: ImportTransaction): void {
transaction.valid = transaction.isTransactionValid();
if (transaction.categoryId && allCategoriesMap.value[transaction.categoryId]) {
transaction.actualCategoryName = allCategoriesMap.value[transaction.categoryId].name;
}
if (transaction.sourceAccountId && allAccountsMap.value[transaction.sourceAccountId]) {
transaction.actualSourceAccountName = allAccountsMap.value[transaction.sourceAccountId].name;
}
if (transaction.destinationAccountId && allAccountsMap.value[transaction.destinationAccountId]) {
transaction.actualDestinationAccountName = allAccountsMap.value[transaction.destinationAccountId].name;
}
}
function showBatchReplaceDialog(type: BatchReplaceDialogDataType, allSourceTagItems?: NameValue[]): void {
if (isEditing.value) {
return;
}
batchReplaceDialog.value?.open({
mode: 'batchReplace',
type: type,
allSourceTagItems: allSourceTagItems
}).then(result => {
if (!result) {
return;
}
if (type !== 'tag') {
if (!result.targetItem) {
return;
}
}
let updatedCount = 0;
if (props.importTransactions) {
for (const importTransaction of props.importTransactions) {
if (!importTransaction.selected) {
continue;
}
let updated = false;
if (type === 'expenseCategory') {
if (importTransaction.type === TransactionType.Expense) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
}
} else if (type === 'incomeCategory') {
if (importTransaction.type === TransactionType.Income) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
}
} else if (type === 'transferCategory') {
if (importTransaction.type === TransactionType.Transfer) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
}
} else if (type === 'account') {
importTransaction.sourceAccountId = result.targetItem as string;
updated = true;
} else if (type === 'destinationAccount') {
if (importTransaction.type === TransactionType.Transfer) {
importTransaction.destinationAccountId = result.targetItem as string;
updated = true;
}
} else if (type === 'tag') {
let removeIndex: number[] = [];
for (let tagIndex = 0; tagIndex < importTransaction.originalTagNames.length; tagIndex++) {
const originalTagName = importTransaction.originalTagNames ? (importTransaction.originalTagNames[tagIndex] ?? '') : '';
if (originalTagName === result.sourceItem) {
if (result.targetItem) {
importTransaction.tagIds[tagIndex] = result.targetItem;
importTransaction.originalTagNames[tagIndex] = allTagsMap.value[result.targetItem]?.name || '';
} else {
removeIndex.push(tagIndex);
}
updated = true;
}
}
for (const tagIndex of reversed(removeIndex)) {
importTransaction.tagIds.splice(tagIndex, 1);
importTransaction.originalTagNames.splice(tagIndex, 1);
}
}
if (updated) {
updatedCount++;
updateTransactionData(importTransaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: getDisplayCount(updatedCount)
});
}
});
}
function showReplaceInvalidItemDialog(type: BatchReplaceDialogDataType, invalidItems: NameValue[]): void {
if (isEditing.value) {
return;
}
batchReplaceDialog.value?.open({
mode: 'replaceInvalidItems',
type: type,
invalidItems: invalidItems
}).then(result => {
if (!result || (!result.sourceItem && result.sourceItem !== '')) {
return;
}
if (type !== 'tag') {
if (!result.targetItem) {
return;
}
}
let updatedCount = 0;
if (props.importTransactions) {
for (const importTransaction of props.importTransactions) {
if (importTransaction.valid) {
continue;
}
let updated = false;
if (type === 'expenseCategory' || type === 'incomeCategory' || type === 'transferCategory') {
const categoryId = importTransaction.categoryId;
const originalCategoryName = importTransaction.originalCategoryName;
if (importTransaction.type !== TransactionType.ModifyBalance && originalCategoryName === result.sourceItem && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
if (type === 'expenseCategory' && importTransaction.type === TransactionType.Expense) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
} else if (type === 'incomeCategory' && importTransaction.type === TransactionType.Income) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
} else if (type === 'transferCategory' && importTransaction.type === TransactionType.Transfer) {
importTransaction.categoryId = result.targetItem as string;
updated = true;
}
}
} else if (type === 'account') {
const sourceAccountId = importTransaction.sourceAccountId;
const originalSourceAccountName = importTransaction.originalSourceAccountName;
const destinationAccountId = importTransaction.destinationAccountId;
const originalDestinationAccountName = importTransaction.originalDestinationAccountName;
if (originalSourceAccountName === result.sourceItem && (!sourceAccountId || sourceAccountId === '0' || !allAccountsMap.value[sourceAccountId])) {
importTransaction.sourceAccountId = result.targetItem as string;
updated = true;
}
if (importTransaction.type === TransactionType.Transfer && originalDestinationAccountName === result.sourceItem && (!destinationAccountId || destinationAccountId === '0' || !allAccountsMap.value[destinationAccountId])) {
importTransaction.destinationAccountId = result.targetItem as string;
updated = true;
}
} else if (type === 'tag' && importTransaction.tagIds) {
let removeIndex: number[] = [];
for (let tagIndex = 0; tagIndex < importTransaction.tagIds.length; tagIndex++) {
const tagId = importTransaction.tagIds[tagIndex] as string;
const originalTagName = importTransaction.originalTagNames ? (importTransaction.originalTagNames[tagIndex] ?? '') : '';
if (originalTagName === result.sourceItem && (!tagId || tagId === '0' || !allTagsMap.value[tagId])) {
if (result.targetItem) {
importTransaction.tagIds[tagIndex] = result.targetItem;
importTransaction.originalTagNames[tagIndex] = allTagsMap.value[result.targetItem]?.name || '';
} else {
removeIndex.push(tagIndex);
}
updated = true;
}
}
for (const tagIndex of reversed(removeIndex)) {
importTransaction.tagIds.splice(tagIndex, 1);
importTransaction.originalTagNames.splice(tagIndex, 1);
}
}
if (updated) {
updatedCount++;
updateTransactionData(importTransaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: getDisplayCount(updatedCount)
});
}
});
}
function showReplaceAllTypesDialog(): void {
if (isEditing.value) {
return;
}
batchReplaceAllTypesDialog.value?.open({
expenseCategoryNames: allInvalidExpenseCategoryNames.value,
incomeCategoryNames: allInvalidIncomeCategoryNames.value,
transferCategoryNames: allInvalidTransferCategoryNames.value,
accountNames: allInvalidAccountNames.value,
tagNames: allInvalidTransactionTagNames.value
}).then(result => {
if (!result || !result.rules) {
return;
}
let updatedCount = 0;
if (props.importTransactions) {
for (const importTransaction of props.importTransactions) {
let updated = false;
for (const rule of result.rules) {
if (!rule || !rule.dataType || !rule.sourceValue || !rule.targetId) {
continue;
}
if (rule.dataType === 'expenseCategory' || rule.dataType === 'incomeCategory' || rule.dataType === 'transferCategory') {
if (importTransaction.type !== TransactionType.ModifyBalance && importTransaction.originalCategoryName === rule.sourceValue) {
importTransaction.categoryId = rule.targetId;
updated = true;
}
} else if (rule.dataType === 'account') {
if (importTransaction.originalSourceAccountName === rule.sourceValue) {
importTransaction.sourceAccountId = rule.targetId;
updated = true;
}
if (importTransaction.type === TransactionType.Transfer && importTransaction.originalDestinationAccountName === rule.sourceValue) {
importTransaction.destinationAccountId = rule.targetId;
updated = true;
}
} else if (rule.dataType === 'tag' && importTransaction.tagIds) {
for (let tagIndex = 0; tagIndex < importTransaction.tagIds.length; tagIndex++) {
const originalTagName = importTransaction.originalTagNames ? (importTransaction.originalTagNames[tagIndex] ?? '') : '';
if (originalTagName === rule.sourceValue) {
importTransaction.tagIds[tagIndex] = rule.targetId;
updated = true;
}
}
}
}
if (updated) {
updatedCount++;
updateTransactionData(importTransaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: getDisplayCount(updatedCount)
});
}
});
}
function showBatchCreateInvalidItemDialog(type: BatchCreateDialogDataType, invalidItems: NameValue[]): void {
if (isEditing.value) {
return;
}
batchCreateDialog.value?.open({
type: type,
invalidItems: invalidItems
}).then(result => {
if (!result || !result.sourceTargetMap) {
return;
}
let updatedCount = 0;
if (props.importTransactions) {
const sourceTargetMap: Record<string, string> = result.sourceTargetMap;
for (const importTransaction of props.importTransactions) {
if (importTransaction.valid) {
continue;
}
let updated = false;
if (type === 'expenseCategory' || type === 'incomeCategory' || type === 'transferCategory') {
const categoryId = importTransaction.categoryId;
const originalCategoryName = importTransaction.originalCategoryName;
const targetItem = sourceTargetMap[originalCategoryName];
if (importTransaction.type !== TransactionType.ModifyBalance && targetItem && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
if (type === 'expenseCategory' && importTransaction.type === TransactionType.Expense) {
importTransaction.categoryId = targetItem;
updated = true;
} else if (type === 'incomeCategory' && importTransaction.type === TransactionType.Income) {
importTransaction.categoryId = targetItem;
updated = true;
} else if (type === 'transferCategory' && importTransaction.type === TransactionType.Transfer) {
importTransaction.categoryId = targetItem;
updated = true;
}
}
} else if (type === 'tag' && importTransaction.tagIds) {
for (let tagIndex = 0; tagIndex < importTransaction.tagIds.length; tagIndex++) {
const tagId = importTransaction.tagIds[tagIndex] as string;
const originalTagName = importTransaction.originalTagNames ? (importTransaction.originalTagNames[tagIndex] ?? '') : '';
const targetItem = sourceTargetMap[originalTagName];
if (targetItem && (!tagId || tagId === '0' || !allTagsMap.value[tagId])) {
importTransaction.tagIds[tagIndex] = targetItem;
updated = true;
}
}
}
if (updated) {
updatedCount++;
updateTransactionData(importTransaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: getDisplayCount(updatedCount)
});
}
});
}
function convertTransactionType(fromType: TransactionType, toType: TransactionType): void {
if (!props.importTransactions || props.importTransactions.length < 1) {
return;
}
const categoryType = transactionTypeToCategoryType(toType);
if (!categoryType) {
return;
}
const categoryMapByName: Record<string, TransactionCategory> = getSecondaryTransactionMapByName(allCategories.value[categoryType]);
for (const importTransaction of props.importTransactions) {
if (!importTransaction.selected || importTransaction.type !== fromType) {
continue;
}
importTransaction.type = toType;
importTransaction.categoryId = categoryMapByName[importTransaction.originalCategoryName]?.id || '0';
if (importTransaction.type === TransactionType.Transfer) {
importTransaction.destinationAccountId = allAccountsMapByName.value[importTransaction.originalDestinationAccountName || '']?.id || '0';
importTransaction.destinationAmount = importTransaction.sourceAmount;
} else {
if (fromType === TransactionType.Transfer && toType === TransactionType.Income) {
importTransaction.sourceAccountId = importTransaction.destinationAccountId;
importTransaction.sourceAmount = importTransaction.destinationAmount;
}
importTransaction.destinationAccountId = '0';
importTransaction.destinationAmount = 0;
}
updateTransactionData(importTransaction);
}
}
function changeCustomDateFilter(minTime: number, maxTime: number): void {
filters.value.minDatetime = minTime;
filters.value.maxDatetime = maxTime;
showCustomDateRangeDialog.value = false;
}
function onShowDateRangeError(message: string): void {
snackbar.value?.showError(message);
}
function reset(): void {
editingTransaction.value = null;
editingTags.value = [];
filters.value.minDatetime = null;
filters.value.maxDatetime = null;
filters.value.transactionType = null;
filters.value.category = null;
filters.value.account = null;
filters.value.tag = null;
filters.value.description = null;
currentPage.value = 1;
countPerPage.value = 10;
}
function setCountPerPage(count: number): void {
countPerPage.value = count;
}
defineExpose({
filterMenus,
toolMenus,
isEditing,
canImport,
reset,
setCountPerPage
});
</script>
<style>
.import-transaction-table .v-autocomplete.v-input.v-input--density-compact:not(.v-textarea) .v-field__input,
.import-transaction-table .v-select.v-input.v-input--density-compact:not(.v-textarea) .v-field__input {
min-height: inherit;
padding-top: 4px;
}
.import-transaction-table .v-chip.transaction-tag {
margin-inline-end: 4px;
margin-top: 2px;
margin-bottom: 2px;
}
.import-transaction-table .v-chip.transaction-tag > .v-chip__content {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>