Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 11:16:39 -05:00
parent 0b6ff8faf1
commit 2ace39c544
12 changed files with 1844 additions and 39 deletions
+21
View File
@@ -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'),
}
+26
View File
@@ -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[]
}
+322 -16
View File
@@ -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>