Implement Phase 2: end-to-end import system
Complete the import flow from file upload through duplicate resolution to transaction creation. Previously, ResolveDuplicatesAsync was a stub that only incremented counters, FileWatcherService detected files but didn't process them, and ImportsView.vue ignored the API response. Backend: - Add ParsedDataJson column to ImportBatch to persist parsed data between upload and confirm steps - Rewrite ImportService.ResolveDuplicatesAsync to deserialize parsed transactions and create/merge/skip based on user decisions, with payee matching, fingerprinting, and account balance updates - Add GetBatchesAsync for import history - Complete FileWatcherService: wait for file ready, resolve account by filename, call UploadAsync, send SignalR notification, archive file - Add GET /api/imports/batches endpoint Frontend: - Create imports API service (frontend/src/services/imports.ts) - Add ImportBatch, DuplicateResolution types - Rewrite ImportsView.vue with 3-step Vuetify stepper wizard: upload, review with duplicate handling, completion summary, plus import history table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import api from './api'
|
||||
import type { ImportUploadResponse, ImportBatch, ResolveDuplicatesRequest } from '@/types'
|
||||
|
||||
export const importsApi = {
|
||||
upload: (accountId: string, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post<ImportUploadResponse>(`/imports/upload?accountId=${accountId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
confirmBatch: (batchId: string) =>
|
||||
api.post<ImportBatch>(`/imports/batches/${batchId}/confirm`),
|
||||
|
||||
resolveDuplicates: (batchId: string, request: ResolveDuplicatesRequest) =>
|
||||
api.post<ImportBatch>(`/imports/batches/${batchId}/resolve-duplicates`, request),
|
||||
|
||||
getBatches: () =>
|
||||
api.get<ImportBatch[]>('/imports/batches'),
|
||||
}
|
||||
@@ -235,3 +235,29 @@ export interface DuplicateMatch {
|
||||
confidence: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
export interface ImportBatch {
|
||||
id: string
|
||||
accountId: string
|
||||
accountName: string
|
||||
fileName: string
|
||||
fileType: string
|
||||
totalCount: number
|
||||
importedCount: number
|
||||
duplicateCount: number
|
||||
skippedCount: number
|
||||
mergedCount: number
|
||||
status: string
|
||||
importedAt: string
|
||||
}
|
||||
|
||||
export type DuplicateAction = 'Import' | 'Skip' | 'Merge'
|
||||
|
||||
export interface DuplicateResolution {
|
||||
index: number
|
||||
action: DuplicateAction
|
||||
}
|
||||
|
||||
export interface ResolveDuplicatesRequest {
|
||||
resolutions: DuplicateResolution[]
|
||||
}
|
||||
|
||||
@@ -1,42 +1,348 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Import Transactions</h1>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-file-input v-model="file" label="Select file to import" accept=".ofx,.qfx,.csv,.qif" prepend-icon="mdi-upload" />
|
||||
<v-select v-model="selectedAccount" label="Target Account" :items="accounts" item-title="name" item-value="id" />
|
||||
<v-btn color="primary" :disabled="!file || !selectedAccount" @click="uploadFile" :loading="uploading">Import</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<v-card class="mb-6">
|
||||
<v-stepper v-model="step" :items="['Upload', 'Review', 'Complete']" alt-labels>
|
||||
<template v-slot:item.1>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="selectedAccount"
|
||||
label="Target Account"
|
||||
:items="accounts"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:rules="[v => !!v || 'Account is required']"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-file-input
|
||||
v-model="files"
|
||||
label="Select file to import"
|
||||
accept=".ofx,.qfx,.csv,.qif"
|
||||
prepend-icon="mdi-file-upload"
|
||||
show-size
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!files?.length || !selectedAccount"
|
||||
:loading="uploading"
|
||||
@click="uploadFile"
|
||||
prepend-icon="mdi-upload"
|
||||
>
|
||||
Upload & Preview
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.2>
|
||||
<v-card flat>
|
||||
<v-card-text v-if="uploadResult">
|
||||
<div class="d-flex ga-3 mb-4">
|
||||
<v-chip color="primary" variant="elevated">
|
||||
{{ uploadResult.totalTransactions }} Total
|
||||
</v-chip>
|
||||
<v-chip color="success" variant="elevated">
|
||||
{{ uploadResult.newTransactions }} New
|
||||
</v-chip>
|
||||
<v-chip v-if="uploadResult.possibleDuplicates > 0" color="warning" variant="elevated">
|
||||
{{ uploadResult.possibleDuplicates }} Possible Duplicates
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="previewHeaders"
|
||||
:items="previewItems"
|
||||
density="compact"
|
||||
:items-per-page="25"
|
||||
>
|
||||
<template #item.date="{ item }">
|
||||
{{ formatDate(item.date) }}
|
||||
</template>
|
||||
<template #item.amount="{ item }">
|
||||
<span :class="item.amount >= 0 ? 'text-success' : 'text-error'" class="font-weight-medium">
|
||||
{{ formatCurrency(item.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip
|
||||
v-if="!item.duplicateMatch"
|
||||
color="success"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
New
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-else
|
||||
:color="getConfidenceColor(item.duplicateMatch.confidence)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
Duplicate ({{ Math.round(item.duplicateMatch.confidence * 100) }}%)
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.matchReason="{ item }">
|
||||
<span v-if="item.duplicateMatch" class="text-caption text-medium-emphasis">
|
||||
{{ item.duplicateMatch.matchReason }}
|
||||
</span>
|
||||
</template>
|
||||
<template #item.action="{ item }">
|
||||
<v-select
|
||||
v-if="item.duplicateMatch"
|
||||
v-model="resolutions[item.index]"
|
||||
:items="actionOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="min-width: 120px"
|
||||
/>
|
||||
<span v-else class="text-caption text-success">Import</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn variant="text" @click="resetWizard">Back</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="confirming"
|
||||
@click="confirmImport"
|
||||
prepend-icon="mdi-check"
|
||||
>
|
||||
Confirm Import
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.3>
|
||||
<v-card flat>
|
||||
<v-card-text v-if="confirmResult" class="text-center py-8">
|
||||
<v-icon color="success" size="64" class="mb-4">mdi-check-circle</v-icon>
|
||||
<h2 class="text-h5 mb-4">Import Complete</h2>
|
||||
<div class="d-flex justify-center ga-3 mb-4">
|
||||
<v-chip color="success" variant="elevated" size="large">
|
||||
{{ confirmResult.importedCount }} Imported
|
||||
</v-chip>
|
||||
<v-chip v-if="confirmResult.duplicateCount > 0" color="info" variant="elevated" size="large">
|
||||
{{ confirmResult.duplicateCount }} Merged
|
||||
</v-chip>
|
||||
<v-chip v-if="confirmResult.skippedCount > 0" color="default" variant="elevated" size="large">
|
||||
{{ confirmResult.skippedCount }} Skipped
|
||||
</v-chip>
|
||||
</div>
|
||||
<p class="text-body-1 text-medium-emphasis">
|
||||
File: {{ confirmResult.fileName }} | Account: {{ confirmResult.accountName }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="text" @click="resetWizard" prepend-icon="mdi-upload">
|
||||
Import Another
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-stepper>
|
||||
</v-card>
|
||||
|
||||
<h2 class="text-h5 mb-3">Import History</h2>
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="historyHeaders"
|
||||
:items="batches"
|
||||
:loading="loadingBatches"
|
||||
density="compact"
|
||||
:items-per-page="10"
|
||||
no-data-text="No import history yet"
|
||||
>
|
||||
<template #item.importedAt="{ item }">
|
||||
{{ formatDateTime(item.importedAt) }}
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip
|
||||
:color="item.status === 'Confirmed' ? 'success' : item.status === 'Pending' ? 'warning' : 'error'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.summary="{ item }">
|
||||
{{ item.importedCount }} imported
|
||||
<span v-if="item.duplicateCount > 0">, {{ item.duplicateCount }} merged</span>
|
||||
<span v-if="item.skippedCount > 0">, {{ item.skippedCount }} skipped</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="4000">
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import api from '@/services/api'
|
||||
import { importsApi } from '@/services/imports'
|
||||
import type { ImportUploadResponse, ImportBatch, DuplicateAction } from '@/types'
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const accounts = ref(accountsStore.accounts)
|
||||
const file = ref<File | null>(null)
|
||||
const accounts = computed(() => accountsStore.accounts.filter(a => a.isActive && !a.isClosed))
|
||||
|
||||
const step = ref(1)
|
||||
const files = ref<File[]>([])
|
||||
const selectedAccount = ref('')
|
||||
const uploading = ref(false)
|
||||
const confirming = ref(false)
|
||||
const uploadResult = ref<ImportUploadResponse | null>(null)
|
||||
const confirmResult = ref<ImportBatch | null>(null)
|
||||
const resolutions = ref<Record<number, DuplicateAction>>({})
|
||||
const batches = ref<ImportBatch[]>([])
|
||||
const loadingBatches = ref(false)
|
||||
const snackbar = ref({ show: false, text: '', color: 'success' })
|
||||
|
||||
const actionOptions = [
|
||||
{ title: 'Skip', value: 'Skip' as DuplicateAction },
|
||||
{ title: 'Import', value: 'Import' as DuplicateAction },
|
||||
{ title: 'Merge', value: 'Merge' as DuplicateAction },
|
||||
]
|
||||
|
||||
const previewHeaders = [
|
||||
{ title: 'Date', key: 'date', width: '110px' },
|
||||
{ title: 'Amount', key: 'amount', align: 'end' as const, width: '110px' },
|
||||
{ title: 'Payee', key: 'payeeName' },
|
||||
{ title: 'Memo', key: 'memo' },
|
||||
{ title: 'Status', key: 'status', width: '140px' },
|
||||
{ title: 'Match Reason', key: 'matchReason' },
|
||||
{ title: 'Action', key: 'action', width: '140px' },
|
||||
]
|
||||
|
||||
const historyHeaders = [
|
||||
{ title: 'Date', key: 'importedAt', width: '170px' },
|
||||
{ title: 'File', key: 'fileName' },
|
||||
{ title: 'Type', key: 'fileType', width: '70px' },
|
||||
{ title: 'Account', key: 'accountName' },
|
||||
{ title: 'Total', key: 'totalCount', align: 'end' as const, width: '80px' },
|
||||
{ title: 'Summary', key: 'summary' },
|
||||
{ title: 'Status', key: 'status', width: '110px' },
|
||||
]
|
||||
|
||||
const previewItems = computed(() => {
|
||||
return uploadResult.value?.previews ?? []
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await accountsStore.fetchAccounts()
|
||||
accounts.value = accountsStore.accounts
|
||||
await loadBatches()
|
||||
})
|
||||
|
||||
async function uploadFile() {
|
||||
if (!file.value || !selectedAccount.value) return
|
||||
if (!files.value?.length || !selectedAccount.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
await api.post(`/imports/upload?accountId=${selectedAccount.value}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
file.value = null
|
||||
const { data } = await importsApi.upload(selectedAccount.value, files.value[0])
|
||||
uploadResult.value = data
|
||||
|
||||
// Set default resolutions for duplicates
|
||||
for (const preview of data.previews) {
|
||||
if (preview.duplicateMatch) {
|
||||
resolutions.value[preview.index] = preview.duplicateMatch.confidence >= 0.85 ? 'Skip' : 'Import'
|
||||
}
|
||||
}
|
||||
|
||||
step.value = 2
|
||||
} catch (err: any) {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
text: err.response?.data?.error || err.response?.data?.message || 'Upload failed',
|
||||
color: 'error'
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!uploadResult.value) return
|
||||
confirming.value = true
|
||||
try {
|
||||
// Build resolution list for all transactions
|
||||
const allResolutions = uploadResult.value.previews.map(p => ({
|
||||
index: p.index,
|
||||
action: (p.duplicateMatch ? resolutions.value[p.index] ?? 'Skip' : 'Import') as DuplicateAction
|
||||
}))
|
||||
|
||||
const { data } = await importsApi.resolveDuplicates(uploadResult.value.batchId, {
|
||||
resolutions: allResolutions
|
||||
})
|
||||
|
||||
confirmResult.value = data
|
||||
step.value = 3
|
||||
await loadBatches()
|
||||
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
text: `Successfully imported ${data.importedCount} transactions`,
|
||||
color: 'success'
|
||||
}
|
||||
} catch (err: any) {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
text: err.response?.data?.error || err.response?.data?.message || 'Import confirmation failed',
|
||||
color: 'error'
|
||||
}
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatches() {
|
||||
loadingBatches.value = true
|
||||
try {
|
||||
const { data } = await importsApi.getBatches()
|
||||
batches.value = data
|
||||
} catch {
|
||||
// Silently fail - history is non-critical
|
||||
} finally {
|
||||
loadingBatches.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
step.value = 1
|
||||
files.value = []
|
||||
uploadResult.value = null
|
||||
confirmResult.value = null
|
||||
resolutions.value = {}
|
||||
}
|
||||
|
||||
function getConfidenceColor(confidence: number): string {
|
||||
if (confidence >= 0.85) return 'error'
|
||||
if (confidence >= 0.50) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user