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:
@@ -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 />
|
||||
|
||||
@@ -14,6 +14,11 @@ const router = createRouter({
|
||||
name: 'categories',
|
||||
component: () => import('../views/CategoriesView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/importar',
|
||||
name: 'import',
|
||||
component: () => import('../views/ImportView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user