feat(#29): Arcade Neon design system — visual overhaul completo
Implementa o sistema visual Arcade Neon do neon-handoff/ em todo o app: - tokens.css: tokens --fc-*, utilities (fc-pixel/mono/body/glow), form inputs/buttons globais - AppHud.vue: HUD persistente com LV/XP/META + nav chips por rota - BottomNav.vue: nav mobile com 5 rotas - NeonPanel.vue, CharacterSprite.vue, XPBar.vue, HUDStat.vue: componentes base - MonthSwitcher.vue, LevelUpModal.vue - HomeView, CharacterView, TransactionsView, ImportView, CategoriesView, AccountsView, SettingsView: reescritos com Arcade Neon - sprites PNG (lv01–lv15) + sprite-data.json: cosméticos com temas de cor - game.ts: equippedTheme getter para merge de cores do cosmético equipado - 008_cosmetics.sql: seeds alinhados com sprite-data.json Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
+228
-134
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useImportStore, type CSVMapping } from '@/stores/import'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
|
||||
const store = useImportStore()
|
||||
|
||||
@@ -27,10 +28,17 @@ function onFileChange(e: Event) {
|
||||
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
|
||||
const mapping = showCSVConfig.value ? csvMapping.value : undefined
|
||||
await store.preview(selectedFile.value, mapping)
|
||||
await store.preview(selectedFile.value, showCSVConfig.value ? csvMapping.value : undefined)
|
||||
}
|
||||
|
||||
const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length)
|
||||
@@ -42,122 +50,154 @@ function fmt(amount: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>Importar Extrato</h1>
|
||||
<div class="fc-view">
|
||||
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
|
||||
|
||||
<!-- 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>
|
||||
<NeonPanel v-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>
|
||||
<!-- File upload -->
|
||||
<div class="upload-area" @click="fileInput?.click()" @dragover.prevent @drop.prevent="onFileChange">
|
||||
<!-- 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" />
|
||||
<span v-if="!selectedFile">Clique ou arraste um arquivo <strong>.ofx</strong> ou <strong>.csv</strong></span>
|
||||
<span v-else>📄 {{ selectedFile.name }}</span>
|
||||
<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 -->
|
||||
<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">
|
||||
<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>Separador decimal
|
||||
<select v-model="csvMapping.decimal_separator">
|
||||
<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>Formato de data
|
||||
<input v-model="csvMapping.date_format" placeholder="02/01/2006" />
|
||||
<label class="fc-label">
|
||||
Formato de data
|
||||
<input v-model="csvMapping.date_format" placeholder="02/01/2006" class="fc-input" />
|
||||
</label>
|
||||
<label>Coluna da data (0-based)
|
||||
<input type="number" v-model.number="csvMapping.date_column" min="0" />
|
||||
<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>Coluna do valor
|
||||
<input type="number" v-model.number="csvMapping.amount_column" min="0" />
|
||||
<label class="fc-label">
|
||||
Coluna do valor
|
||||
<input type="number" v-model.number="csvMapping.amount_column" min="0" class="fc-input" />
|
||||
</label>
|
||||
<label>Coluna da descrição
|
||||
<input type="number" v-model.number="csvMapping.description_column" min="0" />
|
||||
<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="checkbox-label">
|
||||
<input type="checkbox" v-model="csvMapping.has_header" /> Arquivo tem cabeçalho
|
||||
<label class="fc-label fc-import-cfg__checkbox">
|
||||
<input type="checkbox" v-model="csvMapping.has_header" />
|
||||
Arquivo tem cabeçalho
|
||||
</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>
|
||||
|
||||
<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="fc-import-err fc-mono">{{ store.error }}</p>
|
||||
|
||||
<p v-if="store.error" class="form-error">{{ store.error }}</p>
|
||||
|
||||
<!-- Preview table -->
|
||||
<!-- Preview -->
|
||||
<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 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="error-list">
|
||||
<p v-for="e in store.parseErrors" :key="e" class="form-error">{{ e }}</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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">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>
|
||||
<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>
|
||||
</NeonPanel>
|
||||
|
||||
<div class="confirm-bar">
|
||||
<button class="btn btn-primary" :disabled="store.loading || newCount === 0" @click="store.confirm()">
|
||||
{{ store.loading ? 'Salvando…' : `Confirmar ${newCount} transações` }}
|
||||
<div class="fc-import-confirm">
|
||||
<button
|
||||
class="fc-btn fc-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>
|
||||
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">CANCELAR</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -165,60 +205,114 @@ function fmt(amount: number) {
|
||||
</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; }
|
||||
.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;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem;
|
||||
.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;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--fc-space-3);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.upload-area:hover { border-color: #4f46e5; }
|
||||
.fc-import-drop:hover { border-color: var(--fc-accent-3); }
|
||||
.fc-import-drop--has-file { border-color: var(--fc-green); border-style: solid; }
|
||||
|
||||
.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; }
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
/* CSV config grid */
|
||||
.fc-import-cfg {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--fc-space-3);
|
||||
}
|
||||
|
||||
.error-list { margin-bottom: 0.5rem; }
|
||||
.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-confirm {
|
||||
display: flex;
|
||||
gap: var(--fc-space-3);
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user