Files
carvalho-finances/apps/web/src/views/ImportView.vue
T
Mlcavalho1andClaude Sonnet 4.6 2b8524dcde feat: #44 fatura de cartão com data futura fica pendente até confirmação
- Nova tabela pending_bill_imports (migration 016)
- ImportHandler.Confirm: quando is_credit_card=true e payment_date > hoje,
  salva como pending em vez de inserir transações
- Novos endpoints: GET /pending-bills, POST /pending-bills/:id/confirm,
  DELETE /pending-bills/:id
- Dashboard inclui pending_bill_imports no payload
- Frontend: resultado "fatura salva como pendente" no ImportView
- AccountsView exibe widget de faturas pendentes com ações de confirmar/descartar
- dashboard_test: mock de PendingBillRepo + TransactionRepository

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-28 22:26:42 -03:00

416 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useImportStore, type CSVMapping } from '@/stores/import'
import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useImportStore()
const catStore = useCategoriesStore()
onMounted(() => catStore.fetchAll())
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: ';',
skip_negative: false,
all_expenses: false,
payment_date: '',
})
// Quando "Fatura de cartão" é marcado, configura automaticamente as colunas corretas
watch(() => csvMapping.value.all_expenses, (isCard) => {
if (isCard) {
csvMapping.value.date_column = 0
csvMapping.value.description_column = 1
csvMapping.value.amount_column = 3
csvMapping.value.field_separator = ';'
csvMapping.value.decimal_separator = ','
csvMapping.value.date_format = '02/01/2006'
csvMapping.value.has_header = true
csvMapping.value.skip_negative = true
}
})
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')
}
function onDrop(e: DragEvent) {
const file = e.dataTransfer?.files?.[0]
if (!file) return
selectedFile.value = file
store.reset()
showCSVConfig.value = file.name.toLowerCase().endsWith('.csv')
}
async function doPreview() {
if (!selectedFile.value) return
await store.preview(selectedFile.value, showCSVConfig.value ? csvMapping.value : undefined)
}
const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length)
const dupCount = computed(() => store.rows.filter((r) => r.is_duplicate).length)
const newTotal = computed(() =>
store.rows.filter((r) => !r.is_duplicate).reduce((s, r) => s + r.amount, 0)
)
function fmt(amount: number) {
return amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
</script>
<template>
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
<!-- Result banner: pending bill -->
<NeonPanel v-if="store.result?.pending" variant="success" title="FATURA SALVA COMO PENDENTE">
<div class="fc-import-result">
<span class="fc-mono fc-import-result__stat">
Vencimento: <span class="fc-text-gold">{{ store.result.pending_bill?.payment_date }}</span>
</span>
<span class="fc-mono fc-import-result__stat fc-text-green">
{{ fmt(store.result.pending_bill?.total ?? 0) }}
</span>
<span class="fc-mono fc-import-result__stat" style="color:var(--fc-text-dim);font-size:11px">
Confirme o pagamento em Contas quando pagar a fatura
</span>
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">
IMPORTAR OUTRO
</button>
</div>
</NeonPanel>
<!-- Result banner: imported -->
<NeonPanel v-else-if="store.result" variant="success" title="CONCLUÍDO">
<div class="fc-import-result">
<span class="fc-mono fc-import-result__stat">
<span class="fc-text-green">{{ store.result.imported }}</span> importadas
</span>
<span class="fc-mono fc-import-result__stat">
<span class="fc-text-gold">{{ store.result.duplicates }}</span> duplicatas
</span>
<span v-if="store.result.errors" class="fc-mono fc-import-result__stat">
<span class="fc-text-red">{{ store.result.errors }}</span> erros
</span>
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">
IMPORTAR OUTRO
</button>
</div>
</NeonPanel>
<template v-else>
<!-- Upload area -->
<div
class="fc-import-drop"
:class="{ 'fc-import-drop--has-file': !!selectedFile }"
@click="fileInput?.click()"
@dragover.prevent
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" accept=".ofx,.csv" style="display:none" @change="onFileChange" />
<template v-if="!selectedFile">
<span class="fc-pixel fc-import-drop__icon"></span>
<span class="fc-body fc-import-drop__hint">
Clique ou arraste um arquivo <strong>.ofx</strong> ou <strong>.csv</strong>
</span>
</template>
<template v-else>
<span class="fc-pixel fc-import-drop__icon fc-text-green"></span>
<span class="fc-mono fc-import-drop__fname">{{ selectedFile.name }}</span>
</template>
</div>
<!-- CSV config -->
<NeonPanel v-if="showCSVConfig" title="CONFIG CSV">
<div class="fc-import-cfg">
<label class="fc-label">
Separador de campo
<select v-model="csvMapping.field_separator" class="fc-select">
<option value=";">Ponto e vírgula (;)</option>
<option value=",">, Vírgula (,)</option>
</select>
</label>
<label class="fc-label">
Separador decimal
<select v-model="csvMapping.decimal_separator" class="fc-select">
<option value=",">, Vírgula (1.234,56)</option>
<option value=".">. Ponto (1,234.56)</option>
</select>
</label>
<label class="fc-label">
Formato de data
<input v-model="csvMapping.date_format" placeholder="02/01/2006" class="fc-input" />
</label>
<label class="fc-label">
Coluna da data (0-based)
<input type="number" v-model.number="csvMapping.date_column" min="0" class="fc-input" />
</label>
<label class="fc-label">
Coluna do valor
<input type="number" v-model.number="csvMapping.amount_column" min="0" class="fc-input" />
</label>
<label class="fc-label">
Coluna da descrição
<input type="number" v-model.number="csvMapping.description_column" min="0" class="fc-input" />
</label>
<label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.has_header" />
Arquivo tem cabeçalho
</label>
<label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.all_expenses" />
Fatura de cartão (todos são despesas)
</label>
<label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.skip_negative" />
Ignorar valores negativos
</label>
<label v-if="csvMapping.all_expenses" class="fc-label">
Data de pagamento (vencimento)
<input type="date" v-model="csvMapping.payment_date" class="fc-input" />
</label>
</div>
</NeonPanel>
<div v-if="selectedFile && store.rows.length === 0" class="fc-import-action">
<button
class="fc-btn fc-btn--primary"
:disabled="store.loading"
@click="doPreview"
>
{{ store.loading ? 'PROCESSANDO...' : 'ANALISAR ARQUIVO' }}
</button>
</div>
<p v-if="store.error" class="fc-import-err fc-mono">{{ store.error }}</p>
<!-- Preview -->
<template v-if="store.rows.length > 0">
<div class="fc-import-summary">
<span class="fc-pixel fc-import-badge fc-import-badge--new">{{ newCount }} NOVAS</span>
<span class="fc-pixel fc-import-badge fc-import-badge--dup">{{ dupCount }} DUPL.</span>
<span v-if="store.parseErrors.length" class="fc-pixel fc-import-badge fc-import-badge--err">
{{ store.parseErrors.length }} ERROS
</span>
</div>
<div v-if="store.parseErrors.length" class="fc-import-perrors">
<p v-for="e in store.parseErrors" :key="e" class="fc-mono fc-import-err">{{ e }}</p>
</div>
<NeonPanel title="PRÉVIA">
<div class="fc-import-table-wrap">
<table class="fc-import-table">
<thead>
<tr>
<th class="fc-pixel">DATA</th>
<th class="fc-pixel">DESCRIÇÃO</th>
<th class="fc-pixel">VALOR</th>
<th class="fc-pixel">TIPO</th>
<th class="fc-pixel">CATEGORIA</th>
<th class="fc-pixel">STATUS</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in store.rows" :key="i" :class="{ 'fc-import-table__row--dup': row.is_duplicate }">
<td class="fc-mono">{{ row.date }}</td>
<td class="fc-body">{{ row.description }}</td>
<td class="fc-mono" :class="row.type === 'income' ? 'fc-text-green' : ''">
{{ fmt(row.amount) }}
</td>
<td class="fc-mono">{{ row.type === 'income' ? 'Receita' : 'Gasto' }}</td>
<td>
<select
v-model="row.category_id"
class="fc-select fc-import-cat-select"
:disabled="row.is_duplicate"
>
<option :value="null"></option>
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</td>
<td>
<span v-if="row.is_duplicate" class="fc-pixel fc-import-status fc-import-status--dup">DUP</span>
<span v-else class="fc-pixel fc-import-status fc-import-status--new">NOVO</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="fc-import-total">
<span class="fc-mono fc-import-total__label">TOTAL A IMPORTAR</span>
<span class="fc-mono fc-import-total__value">{{ fmt(newTotal) }}</span>
</div>
</NeonPanel>
<div class="fc-import-confirm">
<button
class="fc-btn fc-btn--primary"
:disabled="store.loading || newCount === 0"
@click="store.confirm(csvMapping.all_expenses, csvMapping.all_expenses ? csvMapping.payment_date : '')"
>
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
</button>
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">CANCELAR</button>
</div>
</template>
</template>
</div>
</template>
<style scoped>
.fc-view {
max-width: 860px;
margin: 0 auto;
padding: var(--fc-space-4);
display: flex;
flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.fc-view__title {
font-size: 11px;
color: var(--fc-accent-2);
}
.fc-import-result {
display: flex;
align-items: center;
gap: var(--fc-space-4);
flex-wrap: wrap;
}
.fc-import-result__stat { font-size: 12px; }
/* Drop zone */
.fc-import-drop {
border: 2px dashed var(--fc-panel-edge);
border-radius: var(--fc-radius);
padding: 40px;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--fc-space-3);
transition: border-color .15s;
}
.fc-import-drop:hover { border-color: var(--fc-accent-3); }
.fc-import-drop--has-file { border-color: var(--fc-green); border-style: solid; }
.fc-import-drop__icon { font-size: 18px; }
.fc-import-drop__hint { font-size: 13px; color: var(--fc-text-dim); }
.fc-import-drop__fname { font-size: 12px; color: var(--fc-text); }
/* CSV config grid */
.fc-import-cfg {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--fc-space-3);
}
.fc-import-cfg__checkbox {
flex-direction: row !important;
align-items: center;
gap: var(--fc-space-2) !important;
font-size: 11px;
}
.fc-import-action { display: flex; }
.fc-import-err {
color: var(--fc-red);
font-size: 11px;
}
/* Badges */
.fc-import-summary { display: flex; gap: var(--fc-space-2); flex-wrap: wrap; }
.fc-import-badge {
font-size: 7px;
padding: 4px 8px;
border-radius: 2px;
}
.fc-import-badge--new { background: rgba(57,255,122,.15); color: var(--fc-green); }
.fc-import-badge--dup { background: rgba(255,216,77,.15); color: var(--fc-gold); }
.fc-import-badge--err { background: rgba(255,59,107,.15); color: var(--fc-red); }
/* Preview table */
.fc-import-table-wrap { overflow-x: auto; }
.fc-import-table {
width: 100%;
border-collapse: collapse;
}
.fc-import-table th {
font-size: 7px;
color: var(--fc-text-dim);
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--fc-panel-edge);
}
.fc-import-table td {
font-size: 12px;
padding: 7px 10px;
border-bottom: 1px dashed var(--fc-panel-edge);
}
.fc-import-table__row--dup { opacity: .4; }
.fc-import-status {
font-size: 6px;
padding: 3px 6px;
border-radius: 2px;
}
.fc-import-status--new { background: rgba(57,255,122,.15); color: var(--fc-green); }
.fc-import-status--dup { background: rgba(255,216,77,.15); color: var(--fc-gold); }
.fc-import-total {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--fc-space-3);
padding: var(--fc-space-3) var(--fc-space-2) 0;
border-top: 1px solid var(--fc-panel-edge);
margin-top: var(--fc-space-2);
}
.fc-import-total__label {
font-size: 10px;
color: var(--fc-text-dim);
letter-spacing: .08em;
}
.fc-import-total__value {
font-size: 15px;
color: var(--fc-accent-2);
font-weight: 600;
}
.fc-import-cat-select {
font-size: 10px;
padding: 2px 4px;
min-width: 110px;
height: 26px;
}
.fc-import-confirm {
display: flex;
gap: var(--fc-space-3);
align-items: center;
}
</style>