feat(#18): import de extratos OFX e CSV — parser, dedup, preview e confirmação

Parser OFX tolerante ao formato SGML de bancos brasileiros (sem closing tags, BOM,
timezone suffix). Parser CSV com separador de campo/decimal configurável. Dedup por
FITID (OFX) ou date+amount+desc normalizado. Fluxo: POST /preview → revisão na UI
→ POST /confirm salva e loga. Migration 002 adiciona external_id com unique index.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 20:17:46 -03:00
co-authored by Claude Sonnet 4.6
parent d6782d51a5
commit 83dff2de78
15 changed files with 1065 additions and 3 deletions
+1
View File
@@ -7,6 +7,7 @@
<nav class="app-nav">
<RouterLink to="/">Início</RouterLink>
<RouterLink to="/categorias">Categorias</RouterLink>
<RouterLink to="/importar">Importar</RouterLink>
</nav>
</header>
<RouterView />
+5
View File
@@ -14,6 +14,11 @@ const router = createRouter({
name: 'categories',
component: () => import('../views/CategoriesView.vue'),
},
{
path: '/importar',
name: 'import',
component: () => import('../views/ImportView.vue'),
},
],
})
+96
View File
@@ -0,0 +1,96 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ImportRow {
date: string
amount: number
description: string
type: string
external_id?: string
is_duplicate: boolean
}
export interface ImportResult {
imported: number
duplicates: number
errors: number
}
export interface CSVMapping {
date_column: number
amount_column: number
description_column: number
date_format: string
has_header: boolean
decimal_separator: string
field_separator: string
}
export const useImportStore = defineStore('import', () => {
const rows = ref<ImportRow[]>([])
const parseErrors = ref<string[]>([])
const filename = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<ImportResult | null>(null)
async function preview(file: File, csvMapping?: CSVMapping) {
loading.value = true
error.value = null
result.value = null
filename.value = file.name
const form = new FormData()
form.append('file', file)
if (csvMapping) {
form.append('csv_mapping', JSON.stringify(csvMapping))
}
try {
const res = await fetch('/api/imports/preview', { method: 'POST', body: form })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Erro no preview')
rows.value = data.rows ?? []
parseErrors.value = data.parse_errors ?? []
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function confirm() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/imports/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: filename.value,
rows: rows.value,
parse_error_count: parseErrors.value.length,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Erro ao confirmar')
result.value = data
rows.value = []
parseErrors.value = []
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function reset() {
rows.value = []
parseErrors.value = []
filename.value = ''
error.value = null
result.value = null
}
return { rows, parseErrors, filename, loading, error, result, preview, confirm, reset }
})
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useImportStore, type CSVMapping } from '@/stores/import'
const store = useImportStore()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const showCSVConfig = ref(false)
const csvMapping = ref<CSVMapping>({
date_column: 0,
amount_column: 1,
description_column: 2,
date_format: '02/01/2006',
has_header: true,
decimal_separator: ',',
field_separator: ';',
})
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
selectedFile.value = file
store.reset()
showCSVConfig.value = file.name.toLowerCase().endsWith('.csv')
}
async function doPreview() {
if (!selectedFile.value) return
const mapping = showCSVConfig.value ? csvMapping.value : undefined
await store.preview(selectedFile.value, mapping)
}
const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length)
const dupCount = computed(() => store.rows.filter((r) => r.is_duplicate).length)
function fmt(amount: number) {
return amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
</script>
<template>
<div class="page">
<h1>Importar Extrato</h1>
<!-- Result banner -->
<div v-if="store.result" class="result-banner">
<strong>Import concluído!</strong>
{{ store.result.imported }} transações importadas ·
{{ store.result.duplicates }} duplicatas ignoradas ·
{{ store.result.errors }} erros
<button class="btn btn-ghost" style="margin-left:1rem" @click="store.reset(); selectedFile = null">
Importar outro
</button>
</div>
<template v-else>
<!-- File upload -->
<div class="upload-area" @click="fileInput?.click()" @dragover.prevent @drop.prevent="onFileChange">
<input ref="fileInput" type="file" accept=".ofx,.csv" style="display:none" @change="onFileChange" />
<span v-if="!selectedFile">Clique ou arraste um arquivo <strong>.ofx</strong> ou <strong>.csv</strong></span>
<span v-else>📄 {{ selectedFile.name }}</span>
</div>
<!-- CSV config -->
<div v-if="showCSVConfig" class="csv-config">
<h3>Configuração do CSV</h3>
<div class="config-grid">
<label>Separador de campo
<select v-model="csvMapping.field_separator">
<option value=";">Ponto e vírgula (;)</option>
<option value=",">, Vírgula (,)</option>
</select>
</label>
<label>Separador decimal
<select v-model="csvMapping.decimal_separator">
<option value=",">, Vírgula (1.234,56)</option>
<option value=".">. Ponto (1,234.56)</option>
</select>
</label>
<label>Formato de data
<input v-model="csvMapping.date_format" placeholder="02/01/2006" />
</label>
<label>Coluna da data (0-based)
<input type="number" v-model.number="csvMapping.date_column" min="0" />
</label>
<label>Coluna do valor
<input type="number" v-model.number="csvMapping.amount_column" min="0" />
</label>
<label>Coluna da descrição
<input type="number" v-model.number="csvMapping.description_column" min="0" />
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="csvMapping.has_header" /> Arquivo tem cabeçalho
</label>
</div>
</div>
<button
v-if="selectedFile && store.rows.length === 0"
class="btn btn-primary"
:disabled="store.loading"
@click="doPreview"
>
{{ store.loading ? 'Processando…' : 'Analisar arquivo' }}
</button>
<p v-if="store.error" class="form-error">{{ store.error }}</p>
<!-- Preview table -->
<template v-if="store.rows.length > 0">
<div class="preview-summary">
<span class="badge-new">{{ newCount }} novas</span>
<span class="badge-dup">{{ dupCount }} duplicatas</span>
<span v-if="store.parseErrors.length" class="badge-err">{{ store.parseErrors.length }} erros de parse</span>
</div>
<div v-if="store.parseErrors.length" class="error-list">
<p v-for="e in store.parseErrors" :key="e" class="form-error">{{ e }}</p>
</div>
<div class="table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Data</th>
<th>Descrição</th>
<th>Valor</th>
<th>Tipo</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in store.rows"
:key="i"
:class="{ 'row-dup': row.is_duplicate }"
>
<td>{{ row.date }}</td>
<td>{{ row.description }}</td>
<td :class="row.type === 'income' ? 'amt-income' : 'amt-expense'">
{{ fmt(row.amount) }}
</td>
<td>{{ row.type === 'income' ? 'Receita' : 'Gasto' }}</td>
<td>
<span v-if="row.is_duplicate" class="badge badge-dup-sm">duplicata</span>
<span v-else class="badge badge-new-sm">novo</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="confirm-bar">
<button class="btn btn-primary" :disabled="store.loading || newCount === 0" @click="store.confirm()">
{{ store.loading ? 'Salvando' : `Confirmar ${newCount} transações` }}
</button>
<button class="btn btn-ghost" @click="store.reset(); selectedFile = null">Cancelar</button>
</div>
</template>
</template>
</div>
</template>
<style scoped>
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; }
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; }
h3 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; }
.upload-area {
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 2.5rem;
text-align: center;
cursor: pointer;
color: #6b7280;
margin-bottom: 1rem;
transition: border-color 0.15s;
}
.upload-area:hover { border-color: #4f46e5; }
.csv-config { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.config-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
.config-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #374151; }
.config-grid input, .config-grid select { padding: 0.4rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.875rem; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 0.5rem !important; }
.btn { padding: 0.5rem 1.25rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 0.875rem; background: #fff; }
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-ghost { background: transparent; }
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0; }
.preview-summary { display: flex; gap: 0.75rem; margin: 1rem 0 0.5rem; flex-wrap: wrap; }
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
.badge-new { background: #d1fae5; color: #065f46; }
.badge-dup { background: #fef3c7; color: #92400e; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-new-sm { background: #d1fae5; color: #065f46; font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 3px; }
.badge-dup-sm { background: #fef3c7; color: #92400e; font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 3px; }
.table-wrap { overflow-x: auto; margin: 0.5rem 0; }
.preview-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.preview-table th { text-align: left; padding: 0.5rem 0.75rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.preview-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid #f3f4f6; }
.row-dup { opacity: 0.45; }
.amt-income { color: #059669; font-weight: 500; }
.amt-expense { color: #dc2626; font-weight: 500; }
.confirm-bar { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
.result-banner {
background: #d1fae5;
border: 1px solid #6ee7b7;
border-radius: 8px;
padding: 1rem 1.25rem;
color: #065f46;
margin-bottom: 1rem;
}
.error-list { margin-bottom: 0.5rem; }
</style>