- 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]>
416 lines
14 KiB
Vue
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>
|