Files
ezbookkeeping/src/views/desktop/transactions/import/ImportDialog.vue
T
2025-08-17 01:55:19 +08:00

2763 lines
141 KiB
Vue

<template>
<v-dialog :persistent="!!persistent" v-model="showState">
<v-card class="pa-6 pa-sm-10 pa-md-12">
<template #title>
<div class="d-flex align-center justify-center">
<div class="d-flex w-100 align-center justify-center">
<h4 class="text-h4">{{ tt('Import Transactions') }}</h4>
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</div>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:icon="true" :disabled="loading || submitting"
v-if="currentStep === 'defineColumn'">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent" max-height="500">
<v-list>
<v-list-item :prepend-icon="mdiFolderOpenOutline"
:title="tt('Load Data Mapping File')"
@click="loadColumnMappingFile()"></v-list-item>
<v-list-item :prepend-icon="mdiContentSaveOutline"
:title="tt('Save Data Mapping File')"
@click="saveColumnMappingFile()"></v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:icon="true" :disabled="loading || submitting"
v-if="currentStep === 'checkData'">
<v-icon :icon="mdiFilterOutline" />
<v-menu activator="parent" max-height="500">
<v-list>
<v-list-subheader :title="tt('Date Range')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.minDatetime === null || filters.maxDatetime === null ? mdiCheck : undefined"
@click="filters.minDatetime = filters.maxDatetime = null"></v-list-item>
<v-list-item :title="tt('Custom')"
:subtitle="displayFilterCustomDateRange"
:append-icon="filters.minDatetime !== null && filters.maxDatetime !== null ? mdiCheck : undefined"
@click="showCustomDateRangeDialog = true"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Type')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.transactionType === null ? mdiCheck : undefined"
@click="filters.transactionType = null"></v-list-item>
<v-list-item :title="tt('Income')"
:append-icon="filters.transactionType === TransactionType.Income ? mdiCheck : undefined"
@click="filters.transactionType = TransactionType.Income"></v-list-item>
<v-list-item :title="tt('Expense')"
:append-icon="filters.transactionType === TransactionType.Expense ? mdiCheck : undefined"
@click="filters.transactionType = TransactionType.Expense"></v-list-item>
<v-list-item :title="tt('Transfer')"
:append-icon="filters.transactionType === TransactionType.Transfer ? mdiCheck : undefined"
@click="filters.transactionType = TransactionType.Transfer"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Category')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.category === null ? mdiCheck : undefined"
@click="filters.category = null"></v-list-item>
<v-list-item :title="tt('Invalid Category')"
:append-icon="filters.category === undefined ? mdiCheck : undefined"
@click="filters.category = undefined"></v-list-item>
<v-list-item :title="tt('None')"
:append-icon="filters.category === '' ? mdiCheck : undefined"
@click="filters.category = ''"></v-list-item>
<v-list-item :title="name" :key="name"
:append-icon="filters.category === name ? mdiCheck : undefined"
v-for="name in allUsedCategoryNames"
@click="filters.category = name"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Account')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.account === null ? mdiCheck : undefined"
@click="filters.account = null"></v-list-item>
<v-list-item :title="tt('Invalid Account')"
:append-icon="filters.account === undefined ? mdiCheck : undefined"
@click="filters.account = undefined"></v-list-item>
<v-list-item :title="tt('None')"
:append-icon="filters.account === '' ? mdiCheck : undefined"
@click="filters.account = ''"></v-list-item>
<v-list-item :title="name" :key="name"
:append-icon="filters.account === name ? mdiCheck : undefined"
v-for="name in allUsedAccountNames"
@click="filters.account = name"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Tags')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.tag === null ? mdiCheck : undefined"
@click="filters.tag = null"></v-list-item>
<v-list-item :title="tt('Invalid Tag')"
:append-icon="filters.tag === undefined ? mdiCheck : undefined"
@click="filters.tag = undefined"></v-list-item>
<v-list-item :title="tt('None')"
:append-icon="filters.tag === '' ? mdiCheck : undefined"
@click="filters.tag = ''"></v-list-item>
<v-list-item :title="name" :key="name"
:append-icon="filters.tag === name ? mdiCheck : undefined"
v-for="name in allUsedTagNames"
@click="filters.tag = name"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Description')"/>
<v-list-item :title="tt('All')"
:append-icon="filters.description === null ? mdiCheck : undefined"
@click="filters.description = null"></v-list-item>
<v-list-item :title="tt('None')"
:append-icon="filters.description === '' ? mdiCheck : undefined"
@click="filters.description = ''"></v-list-item>
<v-list-item :title="tt('Custom')"
:subtitle="filters.description !== null ? filters.description : undefined"
:append-icon="filters.description !== null && filters.description !== '' ? mdiCheck : undefined"
@click="currentDescriptionFilterValue = filters.description || ''; showCustomDescriptionDialog = true"></v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:icon="true" :disabled="loading || submitting"
v-if="currentStep === 'checkData'">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent" max-height="500">
<v-list>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || selectedExpenseTransactionCount < 1"
:title="tt('Batch Replace Selected Expense Categories')"
@click="showBatchReplaceDialog('expenseCategory')"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || selectedIncomeTransactionCount < 1"
:title="tt('Batch Replace Selected Income Categories')"
@click="showBatchReplaceDialog('incomeCategory')"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || selectedTransferTransactionCount < 1"
:title="tt('Batch Replace Selected Transfer Categories')"
@click="showBatchReplaceDialog('transferCategory')"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || selectedImportTransactionCount < 1"
:title="tt('Batch Replace Selected Accounts')"
@click="showBatchReplaceDialog('account')"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || selectedTransferTransactionCount < 1"
:title="tt('Batch Replace Selected Destination Accounts')"
@click="showBatchReplaceDialog('destinationAccount')"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || !allInvalidExpenseCategoryNames || allInvalidExpenseCategoryNames.length < 1"
:title="tt('Replace Invalid Expense Categories')"
@click="showReplaceInvalidItemDialog('expenseCategory', allInvalidExpenseCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || !allInvalidIncomeCategoryNames || allInvalidIncomeCategoryNames.length < 1"
:title="tt('Replace Invalid Income Categories')"
@click="showReplaceInvalidItemDialog('incomeCategory', allInvalidIncomeCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || !allInvalidTransferCategoryNames || allInvalidTransferCategoryNames.length < 1"
:title="tt('Replace Invalid Transfer Categories')"
@click="showReplaceInvalidItemDialog('transferCategory', allInvalidTransferCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || !allInvalidAccountNames || allInvalidAccountNames.length < 1"
:title="tt('Replace Invalid Accounts')"
@click="showReplaceInvalidItemDialog('account', allInvalidAccountNames)"></v-list-item>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction || !allInvalidTransactionTagNames || allInvalidTransactionTagNames.length < 1"
:title="tt('Replace Invalid Transaction Tags')"
@click="showReplaceInvalidItemDialog('tag', allInvalidTransactionTagNames)"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiFindReplace"
:disabled="!!editingTransaction"
:title="tt('Batch Replace Categories / Accounts / Tags')"
@click="showReplaceAllTypesDialog()"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiShapePlusOutline"
:disabled="!!editingTransaction || !allInvalidExpenseCategoryNames || allInvalidExpenseCategoryNames.length < 1"
:title="tt('Create Nonexistent Expense Categories')"
@click="showBatchCreateInvalidItemDialog('expenseCategory', allInvalidExpenseCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiShapePlusOutline"
:disabled="!!editingTransaction || !allInvalidIncomeCategoryNames || allInvalidIncomeCategoryNames.length < 1"
:title="tt('Create Nonexistent Income Categories')"
@click="showBatchCreateInvalidItemDialog('incomeCategory', allInvalidIncomeCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiShapePlusOutline"
:disabled="!!editingTransaction || !allInvalidTransferCategoryNames || allInvalidTransferCategoryNames.length < 1"
:title="tt('Create Nonexistent Transfer Categories')"
@click="showBatchCreateInvalidItemDialog('transferCategory', allInvalidTransferCategoryNames)"></v-list-item>
<v-list-item :prepend-icon="mdiShapePlusOutline"
:disabled="!!editingTransaction || !allInvalidTransactionTagNames || allInvalidTransactionTagNames.length < 1"
:title="tt('Create Nonexistent Transaction Tags')"
@click="showBatchCreateInvalidItemDialog('tag', allInvalidTransactionTagNames)"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedExpenseTransactionCount < 1"
:title="tt('Batch Convert Expense Transaction to Income Transaction')"
@click="convertTransactionType(TransactionType.Expense, TransactionType.Income)"></v-list-item>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedExpenseTransactionCount < 1"
:title="tt('Batch Convert Expense Transaction to Transfer Transaction')"
@click="convertTransactionType(TransactionType.Expense, TransactionType.Transfer)"></v-list-item>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedIncomeTransactionCount < 1"
:title="tt('Batch Convert Income Transaction to Expense Transaction')"
@click="convertTransactionType(TransactionType.Income, TransactionType.Expense)"></v-list-item>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedIncomeTransactionCount < 1"
:title="tt('Batch Convert Income Transaction to Transfer Transaction')"
@click="convertTransactionType(TransactionType.Income, TransactionType.Transfer)"></v-list-item>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedTransferTransactionCount < 1"
:title="tt('Batch Convert Transfer Transaction to Expense Transaction')"
@click="convertTransactionType(TransactionType.Transfer, TransactionType.Expense)"></v-list-item>
<v-list-item :prepend-icon="mdiTransfer"
:disabled="!!editingTransaction || selectedTransferTransactionCount < 1"
:title="tt('Batch Convert Transfer Transaction to Income Transaction')"
@click="convertTransactionType(TransactionType.Transfer, TransactionType.Income)"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<div class="mt-4 cursor-default">
<steps-bar min-width="700" :clickable="false" :steps="allSteps" :current-step="currentStep" />
</div>
<v-window class="disable-tab-transition" v-model="currentStep">
<v-window-item value="uploadFile">
<v-row>
<v-col cols="12" md="12">
<v-select
item-title="displayName"
item-value="type"
:disabled="submitting"
:label="tt('File Type')"
:placeholder="tt('File Type')"
:items="allSupportedImportFileTypes"
v-model="fileType"
/>
</v-col>
<v-col cols="12" md="12" v-if="allFileSubTypes">
<v-select
item-title="displayName"
item-value="type"
:disabled="submitting"
:label="tt('Format')"
:placeholder="tt('Format')"
:items="allFileSubTypes"
v-model="fileSubType"
/>
</v-col>
<v-col cols="12" md="12" v-if="!isImportDataFromTextbox && allSupportedEncodings">
<v-select
item-title="displayName"
item-value="encoding"
:disabled="submitting"
:label="tt('File Encoding')"
:placeholder="tt('File Encoding')"
:items="allSupportedEncodings"
v-model="fileEncoding"
/>
</v-col>
<v-col cols="12" md="12" v-if="!isImportDataFromTextbox">
<v-text-field
readonly
persistent-placeholder
type="text"
class="always-cursor-pointer"
:disabled="submitting"
:label="tt('Data File')"
:placeholder="tt('format.misc.clickToSelectedFile', { extensions: supportedImportFileExtensions })"
v-model="fileName"
@click="showOpenFileDialog"
/>
</v-col>
<v-col cols="12" md="12" v-if="isImportDataFromTextbox">
<v-textarea
type="text"
persistent-placeholder
rows="5"
:disabled="submitting"
:placeholder="tt('Data to import')"
v-model="importData"
/>
</v-col>
<v-col cols="12" md="12" class="mb-0 pb-0" v-if="exportFileGuideDocumentUrl">
<a :href="exportFileGuideDocumentUrl" :class="{ 'disabled': submitting }" target="_blank">
<v-icon :icon="mdiHelpCircleOutline" size="16" />
<span class="ml-1" v-if="fileType === 'dsv' || fileType === 'dsv_data'">{{ tt('How to import this file?') }}</span>
<span class="ml-1" v-if="fileType !== 'dsv' && fileType !== 'dsv_data'">{{ tt('How to export this file?') }}</span>
<span class="ml-1" v-if="exportFileGuideDocumentLanguageName">[{{ exportFileGuideDocumentLanguageName }}]</span>
</a>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="defineColumn">
<v-data-table
fixed-header
fixed-footer
density="compact"
item-value="index"
:class="{ 'import-transaction-table': true, 'disabled': loading || submitting }"
:height="parsedFileLinesTableHeight"
:disable-sort="true"
:headers="parsedFileLinesHeaders"
:items="parsedFileLines"
:no-data-text="tt('No data to import')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
>
<template #headers="{ columns }">
<tr>
<th class="text-no-wrap" :key="column.key ?? undefined" v-for="column in columns">
<span v-if="!column.key || column.key === 'index'">{{ column.title }}</span>
<div class="py-1" v-if="column.key && column.key !== 'index'">
<span>{{ getParseDataMappedColumnDisplayName(parseInt(column.key)) }}</span>
<br/>
<span>({{ column.title }})</span>
<v-menu activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="columnType.type"
:append-icon="parsedFileDataColumnMapping.dataColumnMapping[columnType.type] === parseInt(column.key) ? mdiCheck : undefined"
v-for="columnType in allImportTransactionColumnTypes"
@click="parsedFileDataColumnMapping.toggleDataMappingColumn(parseInt(column.key), columnType.type)">
<v-list-item-title class="cursor-pointer">
{{ columnType.displayName }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</th>
</tr>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2" v-if="parsedFileData">
<v-btn color="secondary" density="compact" variant="outlined"
:append-icon="parsedFileDataColumnMapping.includeHeader ? mdiCheck : mdiClose"
@click="parsedFileDataColumnMapping.toggleIncludeHeader()">{{ tt('Include Header Line') }}</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) || !parsedFileAllTransactionTypes">
<span>{{ tt('Transaction Type Mapping') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) && parsedFileAllTransactionTypes">({{ getObjectOwnFieldCount(parsedFileValidMappedTransactionTypes) || tt('None') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
<v-list-item class="pa-0">
<v-table class="transaction-types-popup-menu">
<tbody>
<tr :key="typeName"
v-for="typeName in parsedFileAllTransactionTypes">
<td>{{ typeName }}</td>
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileDataColumnMapping.transactionTypeMapping[typeName]">
<v-btn :value="undefined">{{ tt('None') }}</v-btn>
<v-btn :value="TransactionType.ModifyBalance">{{ tt('Modify Balance') }}</v-btn>
<v-btn :value="TransactionType.Income">{{ tt('Income') }}</v-btn>
<v-btn :value="TransactionType.Expense">{{ tt('Expense') }}</v-btn>
<v-btn :value="TransactionType.Transfer">{{ tt('Transfer') }}</v-btn>
</v-btn-toggle>
</td>
</tr>
</tbody>
</v-table>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">
<span>{{ tt('Time Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">({{ parsedFileDataColumnMapping.timeFormat || parsedFileAutoDetectedTimeFormat || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.timeFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timeFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedTimeFormat">({{ parsedFileAutoDetectedTimeFormat }})</span>
<span class="ml-1" v-if="!parsedFileAutoDetectedTimeFormat">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="dateTimeFormat.format"
:append-icon="parsedFileDataColumnMapping.timeFormat === dateTimeFormat.format ? mdiCheck : undefined"
v-for="dateTimeFormat in KnownDateTimeFormat.values()"
@click="parsedFileDataColumnMapping.timeFormat = dateTimeFormat.format">
<v-list-item-title class="cursor-pointer">
{{ dateTimeFormat.format }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">
<span>{{ tt('Timezone Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">({{ KnownDateTimezoneFormat.valueOf(parsedFileDataColumnMapping.timezoneFormat || parsedFileAutoDetectedTimezoneFormat || '')?.name || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.timezoneFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timezoneFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedTimezoneFormat && KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')">({{ KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')?.name }})</span>
<span class="ml-1" v-if="!parsedFileAutoDetectedTimezoneFormat || !KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="timezoneFormat.value"
:append-icon="parsedFileDataColumnMapping.timezoneFormat === timezoneFormat.value ? mdiCheck : undefined"
v-for="timezoneFormat in KnownDateTimezoneFormat.values()"
@click="parsedFileDataColumnMapping.timezoneFormat = timezoneFormat.value">
<v-list-item-title class="cursor-pointer">
{{ timezoneFormat.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">
<span>{{ tt('Amount Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">({{ KnownAmountFormat.valueOf(parsedFileDataColumnMapping.amountFormat || parsedFileAutoDetectedAmountFormat || '')?.format || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.amountFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.amountFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedAmountFormat && KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')?.format }})</span>
<span class="ml-1" v-if="!parsedFileAutoDetectedAmountFormat || !KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="amountFormat.type"
:append-icon="parsedFileDataColumnMapping.amountFormat === amountFormat.type ? mdiCheck : undefined"
v-for="amountFormat in KnownAmountFormat.values()"
@click="parsedFileDataColumnMapping.amountFormat = amountFormat.type">
<v-list-item-title class="cursor-pointer">
{{ amountFormat.format }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.GeographicLocation)">
<span>{{ tt('Geographic Location Separator') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping.geoLocationOrder">({{ parsedFileDataColumnMapping.formatGeoLocation(tt('Latitude'), tt('Longitude')) }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
<v-list-item class="pa-0">
<v-table class="transaction-types-popup-menu">
<tbody>
<tr :key="separator.value"
v-for="separator in allSeparators">
<td>{{ separator.name }} ({{separator.value}})</td>
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileDataColumnMapping.geoLocationOrder"
v-if="parsedFileDataColumnMapping.geoLocationSeparator === separator.value">
<v-btn value="latlon">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn value="lonlat">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-toggle>
<v-btn-group class="transaction-types-toggle" density="compact" variant="outlined"
divided v-if="parsedFileDataColumnMapping.geoLocationSeparator !== separator.value">
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'latlon')">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'lonlat')">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-group>
</td>
</tr>
</tbody>
</v-table>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Tags)">
<span>{{ tt('Transaction Tags Separator') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping.tagSeparator">({{ parsedFileDataColumnMapping.tagSeparator }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="separator.value"
:append-icon="parsedFileDataColumnMapping.tagSeparator === separator.value ? mdiCheck : undefined"
v-for="separator in allSeparators"
@click="parsedFileDataColumnMapping.tagSeparator = separator.value">
<v-list-item-title class="cursor-pointer">
{{ separator.name }} ({{separator.value}})
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-spacer/>
<span>{{ tt('Lines Per Page') }}</span>
<v-select class="ml-2" density="compact" max-width="100"
item-title="title"
item-value="value"
:disabled="loading || submitting"
:items="parsedFileLinesTablePageOptions"
v-model="countPerPage"
/>
<pagination-buttons density="compact"
:disabled="loading || submitting"
:totalPageCount="Math.ceil((parsedFileLines ? parsedFileLines.length : 0) / countPerPage)"
v-model="currentPage"></pagination-buttons>
</div>
</template>
</v-data-table>
</v-window-item>
<v-window-item value="checkData">
<v-data-table
fixed-header
fixed-footer
show-select
multi-sort
density="compact"
item-value="index"
:class="{ 'import-transaction-table': true, 'disabled': loading || submitting }"
: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="loading || submitting"
: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="loading || submitting"
@click="selectAllValid"></v-list-item>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All Invalid Items')"
:disabled="loading || submitting"
@click="selectAllInvalid"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All')"
:disabled="loading || submitting"
@click="selectAll"></v-list-item>
<v-list-item :prepend-icon="mdiSelect"
:title="tt('Select None')"
:disabled="loading || submitting"
@click="selectNone"></v-list-item>
<v-list-item :prepend-icon="mdiSelectInverse"
:title="tt('Invert Selection')"
:disabled="loading || submitting"
@click="selectInvert"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All in This Page')"
:disabled="loading || submitting"
@click="selectAllInThisPage"></v-list-item>
<v-list-item :prepend-icon="mdiSelect"
:title="tt('Select None in This Page')"
:disabled="loading || submitting"
@click="selectNoneInThisPage"></v-list-item>
<v-list-item :prepend-icon="mdiSelectInverse"
:title="tt('Invert Selection in This Page')"
:disabled="loading || submitting"
@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="loading || submitting"
v-model="item.selected"></v-checkbox>
</template>
<template #item.valid="{ item }">
<v-icon size="small" :class="{ 'text-error': !item.valid }"
:disabled="loading || submitting"
:icon="editingTransaction === item ? mdiCheck : mdiPencilOutline"
@click="editTransaction(item)">
</v-icon>
<v-tooltip activator="parent" v-if="!loading && !submitting">{{ tt('Edit') }}</v-tooltip>
</template>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ml-1" variant="flat" color="secondary" 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="ml-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="mr-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="loading || submitting || !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="loading || submitting || !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="loading || submitting || !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="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="mr-1" :icon="mdiAlertOutline"/>
<span>{{ item.originalSourceAccountName }}</span>
</div>
<v-icon class="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="mr-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="loading || submitting || !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="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="loading || submitting || !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="loading || submitting"
: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: selectedImportTransactionCount, totalCount: importTransactions.length }) }}
</span>
<v-spacer/>
<span>{{ tt('Transactions Per Page') }}</span>
<v-select class="ml-2" density="compact" max-width="100"
item-title="title"
item-value="value"
:disabled="loading || submitting"
:items="importTransactionsTablePageOptions"
v-model="countPerPage"
/>
<pagination-buttons density="compact"
:disabled="loading || submitting"
:totalPageCount="totalPageCount"
v-model="currentPage"></pagination-buttons>
</div>
</template>
</v-data-table>
</v-window-item>
<v-window-item value="finalResult">
<h4 class="text-h4 mb-1">{{ tt('Data Import Completed') }}</h4>
<p class="my-5">{{ tt('format.misc.importTransactionResult', { count: importedCount }) }}</p>
</v-window-item>
</v-window>
<div class="d-flex justify-sm-space-between gap-4 flex-wrap justify-center mt-10">
<v-btn color="secondary" variant="tonal" :disabled="loading || submitting"
:prepend-icon="mdiClose" @click="close(false)"
v-if="currentStep !== 'finalResult'">{{ tt('Cancel') }}</v-btn>
<v-btn color="primary" :disabled="loading || submitting || (!isImportDataFromTextbox && !importFile) || (isImportDataFromTextbox && !importData)"
:append-icon="!submitting ? mdiArrowRight : undefined" @click="parseData"
v-if="currentStep === 'defineColumn' || currentStep === 'uploadFile'">
{{ tt('Next') }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="teal" :disabled="submitting || !!editingTransaction || selectedImportTransactionCount < 1 || selectedInvalidTransactionCount > 0"
:append-icon="!submitting ? mdiArrowRight : undefined" @click="submit"
v-if="currentStep === 'checkData'">
{{ (submitting && importProcess > 0 ? tt('format.misc.importingTransactions', { process: formatNumberToLocalizedNumerals(importProcess, 2) }) : tt('Import')) }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal"
:append-icon="mdiCheck"
@click="close(true)"
v-if="currentStep === 'finalResult'">{{ tt('Close') }}</v-btn>
</div>
</v-card>
</v-dialog>
<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" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
<input ref="fileInput" type="file" style="display: none" :accept="supportedImportFileExtensions" @change="setImportFile($event)" />
</template>
<script setup lang="ts">
import type { StepBarItem } from '@/components/desktop/StepsBar.vue';
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.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, watch } 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 { useTransactionsStore } from '@/stores/transaction.ts';
import { useOverviewStore } from '@/stores/overview.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import type { NameValue, TypeAndDisplayName } from '@/core/base.ts';
import { KnownAmountFormat } from '@/core/numeral.ts';
import { KnownDateTimeFormat } from '@/core/datetime.ts';
import { KnownDateTimezoneFormat } from '@/core/timezone.ts';
import { CategoryType } from '@/core/category.ts';
import { TransactionType } from '@/core/transaction.ts';
import { ImportTransactionColumnType, ImportTransactionDataMapping } from '@/core/import_transaction.ts';
import { KnownFileType } from '@/core/file.ts';
import type { LocalizedImportFileType, LocalizedImportFileTypeSubType, LocalizedImportFileTypeSupportedEncodings } from '@/core/file.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,
isObjectEmpty,
getObjectOwnFieldCount,
findDisplayNameByType,
objectFieldToArrayItem
} from '@/lib/common.ts';
import {
findExtensionByType,
isFileExtensionSupported
} from '@/lib/file.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import logger from '@/lib/logger.ts';
import {
parseDateFromUnixTime,
getUnixTime,
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 {
openTextFileContent,
startDownloadFile
} from '@/lib/ui/common.ts';
import {
mdiFilterOutline,
mdiCheck,
mdiDotsVertical,
mdiHelpCircleOutline,
mdiFolderOpenOutline,
mdiContentSaveOutline,
mdiFindReplace,
mdiShapePlusOutline,
mdiTransfer,
mdiClose,
mdiArrowRight,
mdiSelectAll,
mdiSelect,
mdiSelectInverse,
mdiPencilOutline,
mdiAlertOutline,
mdiPound
} from '@mdi/js';
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
type BatchReplaceDialogType = InstanceType<typeof BatchReplaceDialog>;
type BatchReplaceAllTypesDialogType = InstanceType<typeof BatchReplaceAllTypesDialog>;
type BatchCreateDialogType = InstanceType<typeof BatchCreateDialog>;
type ImportTransactionDialogStep = 'uploadFile' | 'defineColumn' | 'checkData' | 'finalResult';
interface ImportTransactionDialogFilter {
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 ImportTransactionsDialogTablePageOption {
value: number;
title: string;
}
defineProps<{
persistent?: boolean;
}>();
const {
tt,
getAllImportTransactionColumnTypes,
getAllSupportedImportFileTypes,
formatUnixTimeToLongDateTime,
formatAmountToLocalizedNumeralsWithCurrency,
formatNumberToLocalizedNumerals,
getCategorizedAccountsWithDisplayBalance
} = useI18n();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const accountsStore = useAccountsStore();
const transactionCategoriesStore = useTransactionCategoriesStore();
const transactionTagsStore = useTransactionTagsStore();
const transactionsStore = useTransactionsStore();
const overviewStore = useOverviewStore();
const statisticsStore = useStatisticsStore();
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const batchReplaceDialog = useTemplateRef<BatchReplaceDialogType>('batchReplaceDialog');
const batchReplaceAllTypesDialog = useTemplateRef<BatchReplaceAllTypesDialogType>('batchReplaceAllTypesDialog');
const batchCreateDialog = useTemplateRef<BatchCreateDialogType>('batchCreateDialog');
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
const showState = ref<boolean>(false);
const clientSessionId = ref<string>('');
const currentStep = ref<ImportTransactionDialogStep>('uploadFile');
const importProcess = ref<number>(0);
const fileType = ref<string>('ezbookkeeping');
const fileSubType = ref<string>('ezbookkeeping_csv');
const fileEncoding = ref<string>('utf-8');
const importFile = ref<File | null>(null);
const importData = ref<string>('');
const parsedFileData = ref<string[][] | undefined>(undefined);
const parsedFileDataColumnMapping = ref<ImportTransactionDataMapping>(ImportTransactionDataMapping.createEmpty());
const importTransactions = ref<ImportTransaction[] | undefined>(undefined);
const editingTransaction = ref<ImportTransaction | null>(null);
const editingTags = ref<string[]>([]);
const filters = ref<ImportTransactionDialogFilter>({
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 importedCount = ref<number | null>(null);
const showCustomDateRangeDialog = ref<boolean>(false);
const showCustomDescriptionDialog = ref<boolean>(false);
const currentDescriptionFilterValue = ref<string | null>(null);
const loading = ref<boolean>(true);
const submitting = ref<boolean>(false);
let resolveFunc: (() => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
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 allSteps = computed<StepBarItem[]>(() => {
const steps: StepBarItem[] = [
{
name: 'uploadFile',
title: tt('Upload File'),
subTitle: tt('Upload Transaction Data File')
}
];
if (fileType.value === 'dsv' || fileType.value === 'dsv_data') {
steps.push({
name: 'defineColumn',
title: tt('Define Column'),
subTitle: tt('Define and Check Column Mapping')
});
}
steps.push(...[
{
name: 'checkData',
title: tt('Check & Modify'),
subTitle: tt('Check and Modify Your Data')
},
{
name: 'finalResult',
title: tt('Complete'),
subTitle: tt('Data Import Completed')
}
]);
return steps;
});
const allImportTransactionColumnTypes = computed<TypeAndDisplayName[]>(() => getAllImportTransactionColumnTypes());
const allSupportedImportFileTypes = computed<LocalizedImportFileType[]>(() => getAllSupportedImportFileTypes());
const allSeparators = computed<NameValue[]>(() => {
const separators: NameValue[] = [
{
name: tt('Space'),
value: ' '
},
{
name: tt('Comma'),
value: ','
},
{
name: tt('Semicolon'),
value: ';'
},
{
name: tt('Tab'),
value: '\t'
},
{
name: tt('Vertical Bar'),
value: '|'
}
];
return separators;
});
const isImportDataFromTextbox = computed<boolean>(() => {
for (const importFileType of allSupportedImportFileTypes.value) {
if (importFileType.type === fileType.value) {
return !!importFileType.dataFromTextbox;
}
}
return false;
});
const allFileSubTypes = computed<LocalizedImportFileTypeSubType[] | undefined>(() => {
for (const importFileType of allSupportedImportFileTypes.value) {
if (importFileType.type === fileType.value) {
return importFileType.subTypes;
}
}
return undefined;
});
const allSupportedEncodings = computed<LocalizedImportFileTypeSupportedEncodings[] | undefined>(() => {
for (const importFileType of allSupportedImportFileTypes.value) {
if (importFileType.type === fileType.value) {
return importFileType.supportedEncodings;
}
}
return undefined;
});
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 supportedImportFileExtensions = computed<string | undefined>(() => {
if (allFileSubTypes.value && allFileSubTypes.value.length) {
const subTypeExtensions = findExtensionByType(allFileSubTypes.value, fileSubType.value);
if (subTypeExtensions) {
return subTypeExtensions;
}
}
return findExtensionByType(allSupportedImportFileTypes.value, fileType.value);
});
const exportFileGuideDocumentUrl = computed<string | undefined>(() => {
for (const importFileType of allSupportedImportFileTypes.value) {
if (importFileType.type === fileType.value) {
const document = importFileType.document;
if (!document) {
return undefined;
}
const language = document.language ? document.language + '/' : '';
const anchor = document.anchor ? '#' + document.anchor : '';
return `https://ezbookkeeping.mayswind.net/${language}export_and_import${anchor}`;
}
}
return undefined;
});
const exportFileGuideDocumentLanguageName = computed<string | undefined>(() => {
for (const importFileType of allSupportedImportFileTypes.value) {
if (importFileType.type === fileType.value) {
const document = importFileType.document;
return document?.displayLanguageName;
}
}
return undefined;
});
const fileName = computed<string>(() => importFile.value?.name || '');
const parsedFileLines = computed<Record<string, string>[] | undefined>(() => {
if (!parsedFileData.value) {
return undefined;
}
const allLines: Record<string, string>[] = [];
const startIndex = parsedFileDataColumnMapping.value.includeHeader ? 1 : 0;
for (let i = startIndex, index = 1; i < parsedFileData.value.length; i++, index++) {
const line: Record<string, string> = {};
const columns = parsedFileData.value[i];
for (let j = 0; j < columns.length; j++) {
line['index'] = index.toString();
line[`column${j + 1}`] = columns[j];
}
allLines.push(line);
}
return allLines;
});
const parsedFileLinesTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !parsedFileLines.value || parsedFileLines.value.length <= 10) {
return undefined;
} else {
return 400;
}
});
const parsedFileLinesHeaders = computed<object[]>(() => {
let maxColumnCount = 0;
if (parsedFileData.value) {
for (let i = 0; i < parsedFileData.value.length; i++) {
if (parsedFileData.value[i].length > maxColumnCount) {
maxColumnCount = parsedFileData.value[i].length;
}
}
}
const headers: object[] = [];
headers.push({ key: 'index', value: 'index', title: '#', sortable: true, nowrap: true });
for (let i = 0; i < maxColumnCount; i++) {
let title = `#${i + 1}`;
if (parsedFileDataColumnMapping.value.includeHeader && parsedFileData.value && parsedFileData.value[0][i]) {
title = parsedFileData.value[0][i];
}
headers.push({ key: i.toString(), value: `column${i + 1}`, title: title, sortable: true, nowrap: true });
}
return headers;
});
const parsedFileLinesTablePageOptions = computed<ImportTransactionsDialogTablePageOption[]>(() => getTablePageOptions(parsedFileLines.value?.length));
const parsedFileAllTransactionTypes = computed<string[]>(() => parsedFileDataColumnMapping.value.parseFileAllTransactionTypes(parsedFileData.value));
const parsedFileValidMappedTransactionTypes = computed<Record<string, TransactionType>>(() => parsedFileDataColumnMapping.value.parseFileValidMappedTransactionTypes(parsedFileData.value));
const parsedFileAutoDetectedTimeFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimeFormat(parsedFileData.value));
const parsedFileAutoDetectedTimezoneFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimezoneFormat(parsedFileData.value));
const parsedFileAutoDetectedAmountFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedAmountFormat(parsedFileData.value));
const importTransactionsTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !importTransactions.value || importTransactions.value.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<ImportTransactionsDialogTablePageOption[]>(() => getTablePageOptions(importTransactions.value?.length));
const totalPageCount = computed<number>(() => {
if (!importTransactions.value || importTransactions.value.length < 1) {
return 1;
}
let count = 0;
for (let i = 0; i < importTransactions.value.length; i++) {
if (isTransactionDisplayed(importTransactions.value[i])) {
count++;
}
}
return Math.ceil(count / countPerPage.value);
});
const currentPageTransactions = computed<ImportTransaction[]>(() => {
const ret: ImportTransaction[] = [];
if (!importTransactions.value || importTransactions.value.length < 1) {
return ret;
}
const previousCount = Math.max(0, (currentPage.value - 1) * countPerPage.value);
let count = 0;
for (let i = 0; i < importTransactions.value.length; i++) {
if (ret.length >= countPerPage.value) {
break;
}
if (isTransactionDisplayed(importTransactions.value[i])) {
if (count >= previousCount) {
ret.push(importTransactions.value[i]);
}
count++;
}
}
return ret;
});
const selectedImportTransactionCount = computed<number>(() => {
let count = 0;
if (!importTransactions.value || importTransactions.value.length < 1) {
return count;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (importTransactions.value[i].selected) {
count++;
}
}
return count;
});
const selectedExpenseTransactionCount = computed<number>(() => {
let count = 0;
if (!importTransactions.value || importTransactions.value.length < 1) {
return count;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (importTransactions.value[i].selected && importTransactions.value[i].type === TransactionType.Expense) {
count++;
}
}
return count;
});
const selectedIncomeTransactionCount = computed<number>(() => {
let count = 0;
if (!importTransactions.value || importTransactions.value.length < 1) {
return count;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (importTransactions.value[i].selected && importTransactions.value[i].type === TransactionType.Income) {
count++;
}
}
return count;
});
const selectedTransferTransactionCount = computed<number>(() => {
let count = 0;
if (!importTransactions.value || importTransactions.value.length < 1) {
return count;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (importTransactions.value[i].selected && importTransactions.value[i].type === TransactionType.Transfer) {
count++;
}
}
return count;
});
const selectedInvalidTransactionCount = computed<number>(() => {
let count = 0;
if (!importTransactions.value || importTransactions.value.length < 1) {
return count;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (!importTransactions.value[i].valid && importTransactions.value[i].selected) {
count++;
}
}
return count;
});
const anyButNotAllTransactionSelected = computed<boolean>(() => !!importTransactions.value && selectedImportTransactionCount.value > 0 && selectedImportTransactionCount.value !== importTransactions.value.length);
const allTransactionSelected = computed<boolean>(() => !!importTransactions.value && selectedImportTransactionCount.value === importTransactions.value.length);
const allUsedCategoryNames = computed<string[]>(() => {
if (!importTransactions.value || importTransactions.value.length < 1) {
return [];
}
const categoryNames: Record<string, boolean> = {};
for (const transaction of importTransactions.value) {
if (transaction.actualCategoryName && transaction.actualCategoryName !== '') {
categoryNames[transaction.actualCategoryName] = true;
}
}
return objectFieldToArrayItem(categoryNames);
});
const allUsedAccountNames = computed<string[]>(() => {
if (!importTransactions.value || importTransactions.value.length < 1) {
return [];
}
const accountNames: Record<string, boolean> = {};
for (const transaction of importTransactions.value) {
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 (!importTransactions.value || importTransactions.value.length < 1) {
return [];
}
const tagNames: Record<string, boolean> = {};
for (const transaction of importTransactions.value) {
if (!transaction.tagIds || !transaction.originalTagNames) {
continue;
}
for (let j = 0; j < transaction.tagIds.length; j++) {
const tagId = transaction.tagIds[j];
const originalTagName = transaction.originalTagNames[j];
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 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 getTablePageOptions(linesCount?: number): ImportTransactionsDialogTablePageOption[] {
const pageOptions: ImportTransactionsDialogTablePageOption[] = [];
if (!linesCount || linesCount < 1) {
pageOptions.push({ value: -1, title: tt('All') });
return pageOptions;
}
const availableCountPerPage = [ 5, 10, 15, 20, 25, 30, 50 ];
for (let i = 0; i < availableCountPerPage.length; i++) {
const count = availableCountPerPage[i];
if (linesCount < count) {
break;
}
pageOptions.push({ value: count, title: count.toString() });
}
pageOptions.push({ value: -1, title: tt('All') });
return pageOptions;
}
function getParseDataMappedColumnDisplayName(columnIndex: number): string {
for (const columnType in parsedFileDataColumnMapping.value.dataColumnMapping) {
if (parsedFileDataColumnMapping.value.dataColumnMapping[columnType] === columnIndex) {
return findDisplayNameByType(allImportTransactionColumnTypes.value, parseInt(columnType)) || tt('Unspecified');
}
}
return tt('Unspecified');
}
function loadColumnMappingFile(): void {
openTextFileContent({
allowedExtensions: KnownFileType.JSON.contentType
}).then(content => {
const result = ImportTransactionDataMapping.parseFromJson(content);
if (result) {
parsedFileDataColumnMapping.value = result;
} else {
logger.error('Failed to parse data mapping file');
snackbar.value?.showError('Data mapping file is invalid');
}
}).catch(error => {
logger.error('Failed to open data mapping file', error);
snackbar.value?.showError('Data mapping file is invalid');
});
}
function saveColumnMappingFile(): void {
const fileName = KnownFileType.JSON.formatFileName(tt('dataExport.defaultImportDataMappingFileName'));
startDownloadFile(fileName, KnownFileType.JSON.createBlob(parsedFileDataColumnMapping.value.toJson()));
}
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 (let i = 0; i < transaction.tagIds.length; i++) {
const tagId = transaction.tagIds[i];
let tagName = transaction.originalTagNames ? transaction.originalTagNames[i] : "";
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 (let i = 0; i < transaction.tagIds.length; i++) {
if (!transaction.tagIds[i] || transaction.tagIds[i] === '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 {
const transactionTime = getUnixTime(parseDateFromUnixTime(transaction.time, transaction.utcOffset, currentTimezoneOffsetMinutes.value));
return formatUnixTimeToLongDateTime(transactionTime);
}
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 (!importTransactions.value || importTransactions.value.length < 1) {
return invalidCategories;
}
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
const categoryId = transaction.categoryId;
if (transaction.type === transactionType && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
invalidCategoryNames[transaction.originalCategoryName] = true;
}
}
for (const name in invalidCategoryNames) {
if (!Object.prototype.hasOwnProperty.call(invalidCategoryNames, name)) {
continue;
}
invalidCategories.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidCategories;
}
function getCurrentInvalidAccountNames(): NameValue[] {
const invalidAccountNames: Record<string, boolean> = {};
const invalidAccounts: NameValue[] = [];
if (!importTransactions.value || importTransactions.value.length < 1) {
return invalidAccounts;
}
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
const sourceAccountId = transaction.sourceAccountId;
const destinationAccountId = transaction.destinationAccountId;
if (!sourceAccountId || sourceAccountId === '0' || !allAccountsMap.value[sourceAccountId]) {
invalidAccountNames[transaction.originalSourceAccountName] = true;
}
if (transaction.type === TransactionType.Transfer && isString(transaction.originalDestinationAccountName) && (!destinationAccountId || destinationAccountId === '0' || !allAccountsMap.value[destinationAccountId])) {
invalidAccountNames[transaction.originalDestinationAccountName] = true;
}
}
for (const name in invalidAccountNames) {
if (!Object.prototype.hasOwnProperty.call(invalidAccountNames, name)) {
continue;
}
invalidAccounts.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidAccounts;
}
function getCurrentInvalidTagNames(): NameValue[] {
const invalidTagNames: Record<string, boolean> = {};
const invalidTags: NameValue[] = [];
if (!importTransactions.value || importTransactions.value.length < 1) {
return invalidTags;
}
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (!transaction.tagIds || !transaction.originalTagNames) {
continue;
}
for (let j = 0; j < transaction.tagIds.length; j++) {
const tagId = transaction.tagIds[j];
const originalTagName = transaction.originalTagNames[j];
if (!tagId || tagId === '0' || !allTagsMap.value[tagId]) {
invalidTagNames[originalTagName] = true;
}
}
}
for (const name in invalidTagNames) {
if (!Object.prototype.hasOwnProperty.call(invalidTagNames, name)) {
continue;
}
invalidTags.push({
name: name || tt('(Empty)'),
value: name
});
}
return invalidTags;
}
function open(): Promise<void> {
fileType.value = 'ezbookkeeping';
fileSubType.value = 'ezbookkeeping_csv';
fileEncoding.value = 'utf-8';
currentStep.value = 'uploadFile';
importProcess.value = 0;
importFile.value = null;
importData.value = '';
parsedFileData.value = undefined;
parsedFileDataColumnMapping.value.reset();
importTransactions.value = undefined;
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;
showState.value = true;
clientSessionId.value = generateRandomUUID();
const promises = [
accountsStore.loadAllAccounts({ force: false }),
transactionCategoriesStore.loadAllCategories({ force: false }),
transactionTagsStore.loadAllTags({ force: false })
];
Promise.all(promises).then(() => {
loading.value = false;
}).catch(error => {
logger.error('failed to load essential data for importing transaction', error);
loading.value = false;
showState.value = false;
if (!error.processed) {
if (rejectFunc) {
rejectFunc(error);
}
}
});
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function showOpenFileDialog(): void {
if (submitting.value) {
return;
}
fileInput.value?.click();
}
function setImportFile(event: Event): void {
if (!event || !event.target) {
return;
}
const el = event.target as HTMLInputElement;
if (!el.files || !el.files.length) {
return;
}
importFile.value = el.files[0];
el.value = '';
}
function parseData(): void {
let uploadFile: File;
let type: string = fileType.value;
let encoding: string | undefined = undefined;
if (allFileSubTypes.value) {
type = fileSubType.value;
}
if (allSupportedEncodings.value) {
encoding = fileEncoding.value;
}
if (!isImportDataFromTextbox.value) {
if (!importFile.value) {
snackbar.value?.showError('Please select a file to import');
return;
}
uploadFile = importFile.value;
} else if (isImportDataFromTextbox.value) {
if (!importData.value) {
snackbar.value?.showError('No data to import');
return;
}
if (type === 'custom_csv') {
uploadFile = new File([importData.value], 'import.csv', { type: 'text/csv' });
} else if (type === 'custom_tsv') {
uploadFile = new File([importData.value], 'import.tsv', { type: 'text/tab-separated-values' });
} else {
snackbar.value?.showError('Parameter Invalid');
return;
}
encoding = 'utf-8';
} else { // should not happen, but ts would check whether uploadFile has been assigned a value
snackbar.value?.showMessage('An error occurred');
return;
}
const isDsvFileType: boolean = fileType.value === 'dsv' || fileType.value === 'dsv_data';
if (isDsvFileType && currentStep.value === 'uploadFile') {
submitting.value = true;
transactionsStore.parseImportDsvFile({
fileType: type,
fileEncoding: encoding,
importFile: uploadFile
}).then(response => {
if (response && response.length) {
parsedFileData.value = response;
currentPage.value = 1;
countPerPage.value = 10;
currentStep.value = 'defineColumn';
} else {
parsedFileData.value = undefined;
snackbar.value?.showError('No data to import');
}
submitting.value = false;
}).catch(error => {
submitting.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
} else {
let columnMapping: Record<number, number> | undefined = undefined;
let transactionTypeMapping: Record<string, TransactionType> | undefined = undefined;
let hasHeaderLine: boolean | undefined = undefined;
let timeFormat: string | undefined = undefined;
let timezoneFormat: string | undefined = undefined;
let amountFormat: string | undefined = undefined;
let amountDecimalSeparator: string | undefined = undefined;
let amountDigitGroupingSymbol: string | undefined = undefined;
let geoLocationSeparator: string | undefined = undefined;
let geoLocationOrder: string | undefined = undefined;
let tagSeparator: string | undefined = undefined;
if (isDsvFileType) {
columnMapping = parsedFileDataColumnMapping.value.dataColumnMapping;
transactionTypeMapping = parsedFileValidMappedTransactionTypes.value;
hasHeaderLine = parsedFileDataColumnMapping.value.includeHeader;
timeFormat = parsedFileDataColumnMapping.value.timeFormat;
timezoneFormat = parsedFileDataColumnMapping.value.timezoneFormat;
amountFormat = parsedFileDataColumnMapping.value.amountFormat;
geoLocationSeparator = parsedFileDataColumnMapping.value.geoLocationSeparator;
geoLocationOrder = parsedFileDataColumnMapping.value.geoLocationOrder;
tagSeparator = parsedFileDataColumnMapping.value.tagSeparator;
if (!columnMapping
|| !isNumber(columnMapping[ImportTransactionColumnType.TransactionTime.type])
|| !isNumber(columnMapping[ImportTransactionColumnType.TransactionType.type])
|| !isNumber(columnMapping[ImportTransactionColumnType.Amount.type])) {
snackbar.value?.showError('Missing transaction time, transaction type, or amount column mapping');
return;
}
if (!transactionTypeMapping || isObjectEmpty(transactionTypeMapping)) {
snackbar.value?.showError('Transaction type mapping is not set');
return;
}
if (!parsedFileDataColumnMapping.value.timeFormat) {
timeFormat = parsedFileAutoDetectedTimeFormat.value;
}
if (!parsedFileDataColumnMapping.value.timezoneFormat) {
timezoneFormat = parsedFileAutoDetectedTimezoneFormat.value;
}
if (!parsedFileDataColumnMapping.value.amountFormat) {
amountFormat = parsedFileAutoDetectedAmountFormat.value;
}
if (amountFormat) {
const knownAmountFormat = KnownAmountFormat.valueOf(amountFormat);
if (knownAmountFormat) {
amountDecimalSeparator = knownAmountFormat.decimalSeparator.symbol;
amountDigitGroupingSymbol = knownAmountFormat.digitGroupingSymbol?.symbol;
}
}
if (!timeFormat) {
snackbar.value?.showError('Transaction time format is not set');
return;
}
if (!amountDecimalSeparator) {
snackbar.value?.showError('Transaction amount format is not set');
return;
}
}
submitting.value = true;
transactionsStore.parseImportTransaction({
fileType: type,
fileEncoding: encoding,
importFile: uploadFile,
columnMapping: columnMapping,
transactionTypeMapping: transactionTypeMapping,
hasHeaderLine: hasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoSeparator: geoLocationSeparator,
geoOrder: geoLocationOrder,
tagSeparator: tagSeparator
}).then(response => {
const parsedTransactions: ImportTransaction[] = [];
if (response.items) {
for (let i = 0; i < response.items.length; i++) {
const parsedTransaction = ImportTransaction.of(response.items[i], i);
parsedTransactions.push(parsedTransaction);
}
}
importTransactions.value = parsedTransactions;
editingTransaction.value = null;
editingTags.value = [];
currentPage.value = 1;
if (importTransactions.value && importTransactions.value.length >= 0 && importTransactions.value.length < 10) {
countPerPage.value = -1;
} else {
countPerPage.value = 10;
}
currentPage.value = 1;
countPerPage.value = 10;
currentStep.value = 'checkData';
submitting.value = false;
}).catch(error => {
submitting.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
}
function submit(): void {
if (editingTransaction.value) {
return;
}
const transactions: ImportTransaction[] = [];
if (importTransactions.value) {
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (transaction.valid && transaction.selected) {
transactions.push(transaction);
} else if (!transaction.valid && transaction.selected) {
snackbar.value?.showError('Cannot import invalid transactions');
return;
}
}
}
if (transactions.length < 1) {
snackbar.value?.showError('No data to import');
return;
}
confirmDialog.value?.open('format.misc.confirmImportTransactions', {
count: transactions.length
}).then(() => {
editingTransaction.value = null;
editingTags.value = [];
submitting.value = true;
let showProcessTimer : number | undefined = undefined;
if (transactions.length > 100) {
setTimeout(() => {
if (!submitting.value) {
logger.warn('transaction import is not submitting');
return;
}
// @ts-expect-error the return value of setInterval is number, but lint shows it as NodeJS.Timer
showProcessTimer = setInterval(() => {
if (submitting.value) {
transactionsStore.getImportTransactionsProcess({
clientSessionId: clientSessionId.value
}).then(response => {
if (isNumber(response) && 0 <= response && response < 100) {
importProcess.value = response;
} else {
importProcess.value = 0;
clearInterval(showProcessTimer);
showProcessTimer = undefined;
}
}).catch(() => {
importProcess.value = 0;
clearInterval(showProcessTimer);
showProcessTimer = undefined;
});
}
}, 2000);
}, 2000);
}
transactionsStore.importTransactions({
transactions: transactions,
clientSessionId: clientSessionId.value
}).then(response => {
if (showProcessTimer) {
importProcess.value = 0;
clearInterval(showProcessTimer);
showProcessTimer = undefined;
}
importedCount.value = response;
currentStep.value = 'finalResult';
accountsStore.updateAccountListInvalidState(true);
transactionsStore.updateTransactionListInvalidState(true);
overviewStore.updateTransactionOverviewInvalidState(true);
statisticsStore.updateTransactionStatisticsInvalidState(true);
submitting.value = false;
}).catch(error => {
if (showProcessTimer) {
importProcess.value = 0;
clearInterval(showProcessTimer);
showProcessTimer = undefined;
}
submitting.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
});
}
function close(completed: boolean): void {
if (completed) {
if (resolveFunc) {
resolveFunc();
}
} else {
if (rejectFunc) {
rejectFunc();
}
}
showState.value = false;
}
function changeCustomDateFilter(minTime: number, maxTime: number): void {
filters.value.minDatetime = minTime;
filters.value.maxDatetime = maxTime;
showCustomDateRangeDialog.value = false;
}
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 (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (importTransactions.value[i].valid && isTransactionDisplayed(importTransactions.value[i])) {
importTransactions.value[i].selected = true;
}
}
}
function selectAllInvalid(): void {
if (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (!importTransactions.value[i].valid && isTransactionDisplayed(importTransactions.value[i])) {
importTransactions.value[i].selected = true;
}
}
}
function selectAll(): void {
if (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (isTransactionDisplayed(importTransactions.value[i])) {
importTransactions.value[i].selected = true;
}
}
}
function selectNone(): void {
if (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (isTransactionDisplayed(importTransactions.value[i])) {
importTransactions.value[i].selected = false;
}
}
}
function selectInvert(): void {
if (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
for (let i = 0; i < importTransactions.value.length; i++) {
if (isTransactionDisplayed(importTransactions.value[i])) {
importTransactions.value[i].selected = !importTransactions.value[i].selected;
}
}
}
function selectAllInThisPage(): void {
for (let i = 0; i < currentPageTransactions.value.length; i++) {
currentPageTransactions.value[i].selected = true;
}
}
function selectNoneInThisPage(): void {
for (let i = 0; i < currentPageTransactions.value.length; i++) {
currentPageTransactions.value[i].selected = false;
}
}
function selectInvertInThisPage(): void {
for (let i = 0; i < currentPageTransactions.value.length; i++) {
currentPageTransactions.value[i].selected = !currentPageTransactions.value[i].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): void {
if (editingTransaction.value) {
return;
}
batchReplaceDialog.value?.open({
mode: 'batchReplace',
type: type
}).then(result => {
if (!result || !result.targetItem) {
return;
}
let updatedCount = 0;
if (importTransactions.value) {
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (!transaction.selected) {
continue;
}
let updated = false;
if (type === 'expenseCategory') {
if (transaction.type === TransactionType.Expense) {
transaction.categoryId = result.targetItem;
updated = true;
}
} else if (type === 'incomeCategory') {
if (transaction.type === TransactionType.Income) {
transaction.categoryId = result.targetItem;
updated = true;
}
} else if (type === 'transferCategory') {
if (transaction.type === TransactionType.Transfer) {
transaction.categoryId = result.targetItem;
updated = true;
}
} else if (type === 'account') {
transaction.sourceAccountId = result.targetItem;
updated = true;
} else if (type === 'destinationAccount') {
if (transaction.type === TransactionType.Transfer) {
transaction.destinationAccountId = result.targetItem;
updated = true;
}
}
if (updated) {
updatedCount++;
updateTransactionData(transaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: updatedCount
});
}
});
}
function showReplaceInvalidItemDialog(type: BatchReplaceDialogDataType, invalidItems: NameValue[]): void {
if (editingTransaction.value) {
return;
}
batchReplaceDialog.value?.open({
mode: 'replaceInvalidItems',
type: type,
invalidItems: invalidItems
}).then(result => {
if (!result || (!result.sourceItem && result.sourceItem !== '') || !result.targetItem) {
return;
}
let updatedCount = 0;
if (importTransactions.value) {
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (transaction.valid) {
continue;
}
let updated = false;
if (type === 'expenseCategory' || type === 'incomeCategory' || type === 'transferCategory') {
const categoryId = transaction.categoryId;
const originalCategoryName = transaction.originalCategoryName;
if (transaction.type !== TransactionType.ModifyBalance && originalCategoryName === result.sourceItem && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
if (type === 'expenseCategory' && transaction.type === TransactionType.Expense) {
transaction.categoryId = result.targetItem;
updated = true;
} else if (type === 'incomeCategory' && transaction.type === TransactionType.Income) {
transaction.categoryId = result.targetItem;
updated = true;
} else if (type === 'transferCategory' && transaction.type === TransactionType.Transfer) {
transaction.categoryId = result.targetItem;
updated = true;
}
}
} else if (type === 'account') {
const sourceAccountId = transaction.sourceAccountId;
const originalSourceAccountName = transaction.originalSourceAccountName;
const destinationAccountId = transaction.destinationAccountId;
const originalDestinationAccountName = transaction.originalDestinationAccountName;
if (originalSourceAccountName === result.sourceItem && (!sourceAccountId || sourceAccountId === '0' || !allAccountsMap.value[sourceAccountId])) {
transaction.sourceAccountId = result.targetItem;
updated = true;
}
if (transaction.type === TransactionType.Transfer && originalDestinationAccountName === result.sourceItem && (!destinationAccountId || destinationAccountId === '0' || !allAccountsMap.value[destinationAccountId])) {
transaction.destinationAccountId = result.targetItem;
updated = true;
}
} else if (type === 'tag' && transaction.tagIds) {
for (let j = 0; j < transaction.tagIds.length; j++) {
const tagId = transaction.tagIds[j];
const originalTagName = transaction.originalTagNames ? transaction.originalTagNames[j] : "";
if (originalTagName === result.sourceItem && (!tagId || tagId === '0' || !allTagsMap.value[tagId])) {
transaction.tagIds[j] = result.targetItem;
updated = true;
}
}
}
if (updated) {
updatedCount++;
updateTransactionData(transaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: updatedCount
});
}
});
}
function showReplaceAllTypesDialog(): void {
if (editingTransaction.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 (importTransactions.value) {
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
let updated = false;
for (let j = 0; j < result.rules.length; j++) {
const rule = result.rules[j];
if (!rule || !rule.dataType || !rule.sourceValue || !rule.targetId) {
continue;
}
if (rule.dataType === 'expenseCategory' || rule.dataType === 'incomeCategory' || rule.dataType === 'transferCategory') {
if (transaction.type !== TransactionType.ModifyBalance && transaction.originalCategoryName === rule.sourceValue) {
transaction.categoryId = rule.targetId;
updated = true;
}
} else if (rule.dataType === 'account') {
if (transaction.originalSourceAccountName === rule.sourceValue) {
transaction.sourceAccountId = rule.targetId;
updated = true;
}
if (transaction.type === TransactionType.Transfer && transaction.originalDestinationAccountName === rule.sourceValue) {
transaction.destinationAccountId = rule.targetId;
updated = true;
}
} else if (rule.dataType === 'tag' && transaction.tagIds) {
for (let k = 0; k < transaction.tagIds.length; k++) {
const originalTagName = transaction.originalTagNames ? transaction.originalTagNames[k] : "";
if (originalTagName === rule.sourceValue) {
transaction.tagIds[k] = rule.targetId;
updated = true;
}
}
}
}
if (updated) {
updatedCount++;
updateTransactionData(transaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: updatedCount
});
}
});
}
function showBatchCreateInvalidItemDialog(type: BatchCreateDialogDataType, invalidItems: NameValue[]): void {
if (editingTransaction.value) {
return;
}
batchCreateDialog.value?.open({
type: type,
invalidItems: invalidItems
}).then(result => {
if (!result || !result.sourceTargetMap) {
return;
}
let updatedCount = 0;
if (importTransactions.value) {
const sourceTargetMap: Record<string, string> = result.sourceTargetMap;
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (transaction.valid) {
continue;
}
let updated = false;
if (type === 'expenseCategory' || type === 'incomeCategory' || type === 'transferCategory') {
const categoryId = transaction.categoryId;
const originalCategoryName = transaction.originalCategoryName;
const targetItem = sourceTargetMap[originalCategoryName];
if (transaction.type !== TransactionType.ModifyBalance && targetItem && (!categoryId || categoryId === '0' || !allCategoriesMap.value[categoryId])) {
if (type === 'expenseCategory' && transaction.type === TransactionType.Expense) {
transaction.categoryId = targetItem;
updated = true;
} else if (type === 'incomeCategory' && transaction.type === TransactionType.Income) {
transaction.categoryId = targetItem;
updated = true;
} else if (type === 'transferCategory' && transaction.type === TransactionType.Transfer) {
transaction.categoryId = targetItem;
updated = true;
}
}
} else if (type === 'tag' && transaction.tagIds) {
for (let j = 0; j < transaction.tagIds.length; j++) {
const tagId = transaction.tagIds[j];
const originalTagName = transaction.originalTagNames ? transaction.originalTagNames[j] : "";
const targetItem = sourceTargetMap[originalTagName];
if (targetItem && (!tagId || tagId === '0' || !allTagsMap.value[tagId])) {
transaction.tagIds[j] = targetItem;
updated = true;
}
}
}
if (updated) {
updatedCount++;
updateTransactionData(transaction);
}
}
}
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: updatedCount
});
}
});
}
function convertTransactionType(fromType: TransactionType, toType: TransactionType): void {
if (!importTransactions.value || importTransactions.value.length < 1) {
return;
}
const categoryType = transactionTypeToCategoryType(toType);
if (!categoryType) {
return;
}
const categoryMapByName: Record<string, TransactionCategory> = getSecondaryTransactionMapByName(allCategories.value[categoryType]);
for (let i = 0; i < importTransactions.value.length; i++) {
const transaction: ImportTransaction = importTransactions.value[i];
if (!transaction.selected || transaction.type !== fromType) {
continue;
}
transaction.type = toType;
transaction.categoryId = categoryMapByName[transaction.originalCategoryName]?.id || '0';
if (transaction.type === TransactionType.Transfer) {
transaction.destinationAccountId = allAccountsMapByName.value[transaction.originalDestinationAccountName || '']?.id || '0';
transaction.destinationAmount = transaction.sourceAmount;
} else {
if (fromType === TransactionType.Transfer && toType === TransactionType.Income) {
transaction.sourceAccountId = transaction.destinationAccountId;
transaction.sourceAmount = transaction.destinationAmount;
}
transaction.destinationAccountId = '0';
transaction.destinationAmount = 0;
}
updateTransactionData(transaction);
}
}
function onShowDateRangeError(message: string): void {
snackbar.value?.showError(message);
}
watch(fileType, () => {
if (allFileSubTypes.value && allFileSubTypes.value.length) {
fileSubType.value = allFileSubTypes.value[0].type;
}
importFile.value = null;
importTransactions.value = undefined;
editingTransaction.value = null;
editingTags.value = [];
currentPage.value = 1;
countPerPage.value = 10;
});
watch(fileSubType, (newValue) => {
let supportedExtensions: string | undefined = findExtensionByType(allFileSubTypes.value, newValue);
if (!supportedExtensions) {
supportedExtensions = findExtensionByType(allSupportedImportFileTypes.value, fileType.value);
}
if (importFile.value && importFile.value.name && !isFileExtensionSupported(importFile.value.name, supportedExtensions || '')) {
importFile.value = null;
}
});
defineExpose({
open
});
</script>
<style>
.transaction-types-popup-menu .transaction-types-toggle {
overflow-x: auto;
white-space: nowrap;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle {
height: auto !important;
padding: 0;
border: none;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle button.v-btn {
width: auto !important;
}
.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-right: 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>