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:
2026-05-26 22:53:41 -03:00
co-authored by Claude Sonnet 4.6
parent 91ab9c4360
commit c327d5c970
38 changed files with 2576 additions and 911 deletions
+228 -134
View File
@@ -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>