2763 lines
141 KiB
Vue
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>
|