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
@@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS cosmetics ( CREATE TABLE IF NOT EXISTS cosmetics (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL CHECK (type IN ('hair','shirt','pants','shoes','hat','accessory')), type VARCHAR(20) NOT NULL CHECK (type IN ('outfit','hat','accessory')),
unlock_level INTEGER NOT NULL, unlock_level INTEGER NOT NULL,
css_data JSONB NOT NULL DEFAULT '{}' css_data JSONB NOT NULL DEFAULT '{}'
); );
@@ -13,13 +13,20 @@ CREATE TABLE IF NOT EXISTS player_cosmetics (
unlocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() unlocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
); );
-- Seeds aligned with sprite-data.json themes (keys = sprite color slots)
INSERT INTO cosmetics (name, type, unlock_level, css_data) VALUES INSERT INTO cosmetics (name, type, unlock_level, css_data) VALUES
('Cabelo Ruivo', 'hair', 2, '{"color":"#8B1a0a"}'), ('Tripulante Iniciante', 'outfit', 1,
('Camisa Marinheiro', 'shirt', 3, '{"color":"#1e3a5f"}'), '{"sprite_id":"lv01-tripulante","h":"#5a3a1d","w":"#5a3a1d","c":"#0ea5e9","C":"#075985","p":"#a16207","P":"#7c4a08","b":"#1e293b"}'),
('Calças Aventureiro', 'pants', 4, '{"color":"#2d4a1e"}'), ('Boné Verolme', 'outfit', 3,
('Botas de Capitão', 'shoes', 5, '{"color":"#8B4513"}'), '{"sprite_id":"lv03-bone-verolme","h":"#1e40af","w":"#fde68a","c":"#0ea5e9","C":"#075985","p":"#a16207","P":"#7c4a08","b":"#1e293b"}'),
('Camisa Dourada', 'shirt', 7, '{"color":"#d4af37"}'), ('Camiseta Wingspan', 'outfit', 5,
('Cabelo Platinado', 'hair', 10, '{"color":"#e8e8e8"}') '{"sprite_id":"lv05-wingspan","h":"#ffd84d","w":"#fde68a","c":"#10b981","C":"#065f46","p":"#3a1f6e","P":"#2d1857","b":"#0f172a"}'),
('Chapéu de Sol', 'outfit', 7,
'{"sprite_id":"lv07-chapeu-sol","h":"#ffd84d","w":"#fde68a","c":"#00f0ff","C":"#0369a1","p":"#3a1f6e","P":"#2d1857","b":"#0f172a"}'),
('Casaco Anti-Vento', 'outfit', 10,
'{"sprite_id":"lv10-anti-vento","h":"#1e40af","w":"#fde68a","c":"#a855f7","C":"#581c87","p":"#1e293b","P":"#0f172a","b":"#0f172a"}'),
('Trajado de Capitão', 'outfit', 15,
'{"sprite_id":"lv15-capitao","h":"#ffd84d","w":"#dc2626","c":"#dc2626","C":"#7a0e0e","p":"#1c1917","P":"#0c0a09","b":"#0c0a09"}')
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (8) ON CONFLICT DO NOTHING; INSERT INTO schema_migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
+3
View File
@@ -5,6 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Financeiro Carvalho</title> <title>Financeiro Carvalho</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+19 -29
View File
@@ -1,39 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'
import { useGameStore } from '@/stores/game'
import { useDashboardStore } from '@/stores/dashboard'
import AppHud from '@/components/AppHud.vue'
import BottomNav from '@/components/BottomNav.vue'
const gameStore = useGameStore()
const dashStore = useDashboardStore()
onMounted(() => {
gameStore.fetchSummary()
dashStore.fetch(new Date().toISOString().slice(0, 7))
})
</script> </script>
<template> <template>
<header class="app-header"> <div class="fc-app">
<span class="app-title">💰 Financeiro Carvalho</span> <AppHud />
<nav class="app-nav"> <RouterView />
<RouterLink to="/">Início</RouterLink> <BottomNav />
<RouterLink to="/categorias">Categorias</RouterLink> </div>
<RouterLink to="/transacoes">Transações</RouterLink>
<RouterLink to="/importar">Importar</RouterLink>
<RouterLink to="/contas">Contas</RouterLink>
<RouterLink to="/personagem">Personagem</RouterLink>
<RouterLink to="/configuracoes">Configurações</RouterLink>
</nav>
</header>
<RouterView />
</template> </template>
<style> <style>
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #f8fafc; color: #1e293b; }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
</style> button { font-family: inherit; }
<style scoped>
.app-header {
display: flex;
align-items: center;
gap: 2rem;
padding: 0.75rem 1.5rem;
background: #1e293b;
color: #f8fafc;
}
.app-title { font-weight: 700; font-size: 1rem; }
.app-nav { display: flex; gap: 1.25rem; }
.app-nav a { font-size: 0.875rem; opacity: 0.8; }
.app-nav a.router-link-active { opacity: 1; text-decoration: underline; }
</style> </style>
+67
View File
@@ -0,0 +1,67 @@
{
"_note": "Each icon is a flat grid of single-char keys. '.' = transparent. Colors are filled at render time from CSS tokens or props.",
"heart": {
"rows": 8,
"cols": 8,
"grid": [
"........",
".XX..XX.",
"XXXXXXXX",
"XXXXXXXX",
".XXXXXX.",
"..XXXX..",
"...XX...",
"........"
],
"defaultColor": "var(--fc-red, #ff3b6b)"
},
"coin": {
"rows": 8,
"cols": 8,
"grid": [
"..XXXX..",
".XYYYYX.",
"XYYSYYYX",
"XYYSYYYX",
"XYYSYYYX",
"XYYSYYYX",
".XYYYYX.",
"..XXXX.."
],
"colors": {
"X": "var(--fc-gold, #ffd84d)",
"Y": "var(--fc-gold, #ffd84d)",
"S": "#a16207"
}
},
"star": {
"rows": 8,
"cols": 8,
"grid": [
"...XX...",
"...XX...",
"XX.XX.XX",
".XXXXXX.",
"..XXXX..",
".XXXXXX.",
"XX.XX.XX",
"...XX..."
],
"defaultColor": "var(--fc-gold, #ffd84d)"
},
"anchor": {
"rows": 8,
"cols": 8,
"grid": [
"...XX...",
"..XXXX..",
"...XX...",
"XXXXXXXX",
"...XX...",
"X..XX..X",
"XXXXXXXX",
"..XXXX.."
],
"defaultColor": "var(--fc-accent-2, #00f0ff)"
}
}
+160
View File
@@ -0,0 +1,160 @@
{
"name": "manoel",
"rows": 24,
"cols": 17,
"_colorSlots": {
"L": "outline (immutable)",
"s": "skin highlight (immutable)",
"S": "skin shade (immutable)",
"e": "eye (immutable)",
"m": "mouth (immutable)",
"h": "hat main",
"w": "hat band",
"c": "shirt main",
"C": "shirt shade",
"p": "pants main",
"P": "pants shade",
"b": "boots"
},
"_legend": {
".": "transparent",
"L": "#10131c",
"s": "#f3c79b",
"S": "#c98863",
"e": "#10131c",
"m": "#80484b",
"h": "#ffd84d",
"w": "#fde68a",
"c": "#00f0ff",
"C": "#0369a1",
"p": "#3a1f6e",
"P": "#2d1857",
"b": "#0f172a"
},
"grid": [
"....LLLLLLLLL....",
"..LLhhhhhhhhhLL..",
".LhhhhhhhhhhhhhL.",
"LLhhhhhhhhhhhhhLL",
"LwwwwwwwwwwwwwwwL",
"LLLLLLLLLLLLLLLLL",
"....LssssssssL...",
"...LssssssssssL..",
"..LsseSssssSesL..",
"..LssssssssssL...",
"..LSsLmmmmmLSsL..",
"...LSssssssSSL...",
"....LLsssssLL....",
"...LccccccccL....",
"..LcccCwwCccccL..",
".LccccCwwCccccL..",
".LccccCwwCccccL..",
".LCCCCCCCCCCCCL..",
"..LpppLLLLpppL...",
"..LppppppppppL...",
"..LppppppppppL...",
"..LPPPPPPPPPPL...",
"..LLbbLL.LLbbLL..",
"...LLLL...LLLL..."
],
"cosmetics": [
{
"id": "default",
"name": "Look Equipado (atual)",
"unlockLv": 7,
"preview": "manoel-default",
"theme": {}
},
{
"id": "lv01-tripulante",
"name": "Tripulante Iniciante",
"unlockLv": 1,
"preview": "manoel-lv01",
"_notes": "sem chapéu — h/w viram cabelo castanho",
"theme": {
"h": "#5a3a1d",
"w": "#5a3a1d",
"c": "#0ea5e9",
"C": "#075985",
"p": "#a16207",
"P": "#7c4a08",
"b": "#1e293b"
}
},
{
"id": "lv03-bone-verolme",
"name": "Boné Verolme",
"unlockLv": 3,
"preview": "manoel-lv03-bone-verolme",
"theme": {
"h": "#1e40af",
"w": "#fde68a",
"c": "#0ea5e9",
"C": "#075985",
"p": "#a16207",
"P": "#7c4a08",
"b": "#1e293b"
}
},
{
"id": "lv05-wingspan",
"name": "Camiseta Wingspan",
"unlockLv": 5,
"preview": "manoel-lv05-wingspan",
"theme": {
"h": "#ffd84d",
"w": "#fde68a",
"c": "#10b981",
"C": "#065f46",
"p": "#3a1f6e",
"P": "#2d1857",
"b": "#0f172a"
}
},
{
"id": "lv07-chapeu-sol",
"name": "Chapéu de Sol",
"unlockLv": 7,
"preview": "manoel-lv07-chapeu-sol",
"theme": {
"h": "#ffd84d",
"w": "#fde68a",
"c": "#00f0ff",
"C": "#0369a1",
"p": "#3a1f6e",
"P": "#2d1857",
"b": "#0f172a"
}
},
{
"id": "lv10-anti-vento",
"name": "Casaco Anti-Vento",
"unlockLv": 10,
"preview": "manoel-lv10-anti-vento",
"theme": {
"h": "#1e40af",
"w": "#fde68a",
"c": "#a855f7",
"C": "#581c87",
"p": "#1e293b",
"P": "#0f172a",
"b": "#0f172a"
}
},
{
"id": "lv15-capitao",
"name": "Trajado de Capitão",
"unlockLv": 15,
"preview": "manoel-lv15-capitao",
"theme": {
"h": "#ffd84d",
"w": "#dc2626",
"c": "#dc2626",
"C": "#7a0e0e",
"p": "#1c1917",
"P": "#0c0a09",
"b": "#0c0a09"
}
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+117
View File
@@ -0,0 +1,117 @@
<template>
<header class="fc-hud">
<div class="fc-hud__logo">
<span class="fc-hud__logo-mark" />
<div>
<div class="fc-pixel" style="font-size:10px;color:var(--fc-accent-2)">CARVALHO</div>
<div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">FIN.SYS v2.0</div>
</div>
</div>
<div class="fc-hud__sep" />
<div class="fc-hud__stats">
<HUDStat label="LV" :value="profile?.level ?? 1" color="var(--fc-gold)" />
<HUDStat label="XP" :value="profile?.xp ?? 0" :sub="`/${profile?.xp_to_next ?? 100}`" color="var(--fc-accent-2)" />
<HUDStat label="META" :value="metaPct" sub="/40%" color="metaColor" />
</div>
<nav class="fc-hud__nav">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="fc-hud__nav-item fc-pixel"
>{{ item.label }}</RouterLink>
</nav>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useGameStore } from '@/stores/game'
import { useDashboardStore } from '@/stores/dashboard'
import HUDStat from './HUDStat.vue'
const gameStore = useGameStore()
const dashStore = useDashboardStore()
const profile = computed(() => gameStore.summary?.profile)
const metaPct = computed(() => dashStore.data ? `${dashStore.data.savings_pct.toFixed(0)}%` : '--')
const metaColor = computed(() => {
if (!dashStore.data) return 'var(--fc-text-dim)'
return dashStore.data.savings_pct >= 40 ? 'var(--fc-green)' : 'var(--fc-red)'
})
const navItems = [
{ to: '/', label: 'HOME' },
{ to: '/transacoes', label: 'TX' },
{ to: '/importar', label: 'IMPORT' },
{ to: '/contas', label: 'CONTAS' },
{ to: '/personagem', label: 'PERS.' },
{ to: '/configuracoes', label: 'CFG' },
]
</script>
<style scoped>
.fc-hud {
display: flex;
align-items: center;
gap: var(--fc-space-4);
padding: 10px 20px;
border-bottom: 2px solid var(--fc-panel-edge);
background: linear-gradient(180deg, var(--fc-bg-panel), var(--fc-bg));
position: sticky;
top: 0;
z-index: 100;
flex-wrap: wrap;
}
.fc-hud__logo {
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.fc-hud__logo-mark {
display: inline-block; width: 26px; height: 26px;
background: var(--fc-accent);
box-shadow: 0 0 10px var(--fc-accent);
position: relative; flex-shrink: 0;
}
.fc-hud__logo-mark::before {
content: ''; position: absolute; inset: 5px; background: var(--fc-bg);
}
.fc-hud__logo-mark::after {
content: ''; position: absolute; top: 8px; left: 8px; width: 10px; height: 10px;
background: var(--fc-accent-2);
}
.fc-hud__sep {
width: 2px; height: 32px; background: var(--fc-panel-edge); flex-shrink: 0;
}
.fc-hud__stats {
display: flex; gap: var(--fc-space-4); align-items: center; flex-shrink: 0;
}
.fc-hud__nav {
display: flex; gap: var(--fc-space-2); align-items: center; flex-wrap: wrap;
margin-left: auto;
}
.fc-hud__nav-item {
font-size: 8px;
padding: 5px 8px;
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius-sm);
color: var(--fc-text-dim);
text-decoration: none;
transition: all .12s;
white-space: nowrap;
}
.fc-hud__nav-item:hover,
.fc-hud__nav-item.router-link-active {
border-color: var(--fc-accent-3);
color: var(--fc-text);
background: rgba(168,85,247,.15);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
@media (max-width: 767px) {
.fc-hud__nav { display: none; }
.fc-hud__sep { display: none; }
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<nav class="fc-bottom-nav">
<RouterLink to="/" class="fc-bottom-nav__item fc-pixel">HOME</RouterLink>
<RouterLink to="/transacoes" class="fc-bottom-nav__item fc-pixel">TX</RouterLink>
<RouterLink to="/importar" class="fc-bottom-nav__item fc-pixel add">+</RouterLink>
<RouterLink to="/personagem" class="fc-bottom-nav__item fc-pixel">PERS.</RouterLink>
<RouterLink to="/contas" class="fc-bottom-nav__item fc-pixel">¤</RouterLink>
</nav>
</template>
<style scoped>
.fc-bottom-nav {
display: none;
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--fc-bg-panel);
border-top: 2px solid var(--fc-panel-edge);
z-index: 100;
padding: 0 4px env(safe-area-inset-bottom);
}
.fc-bottom-nav__item {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 12px 4px;
font-size: 7px; color: var(--fc-text-dim);
text-decoration: none;
transition: color .12s;
}
.fc-bottom-nav__item.router-link-active { color: var(--fc-accent-2); }
.fc-bottom-nav__item.add {
font-size: 18px; color: var(--fc-accent);
text-shadow: 0 0 8px var(--fc-accent);
}
@media (max-width: 767px) {
.fc-bottom-nav { display: flex; }
}
</style>
@@ -0,0 +1,80 @@
<template>
<svg
:width="cols * scale"
:height="rows * scale"
:viewBox="`0 0 ${cols * scale} ${rows * scale}`"
shape-rendering="crispEdges"
class="fc-sprite"
:class="{ 'fc-sprite--bob': bob && !celebrate, 'fc-sprite--celebrate': celebrate }"
aria-label="Personagem pixel art"
>
<rect
v-for="(px, i) in pixels"
:key="i"
:x="px.x * scale"
:y="px.y * scale"
:width="scale"
:height="scale"
:fill="colors[px.k]"
/>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import spriteData from '@/assets/sprite-data.json'
const props = withDefaults(defineProps<{
scale?: number
theme?: Partial<Record<string, string>>
bob?: boolean
celebrate?: boolean
}>(), { scale: 4, bob: true })
const rows = spriteData.rows
const cols = spriteData.cols
const defaultColors: Record<string, string> = {
L: 'var(--fc-sprite-outline, #10131c)',
s: 'var(--fc-sprite-skin, #f3c79b)',
S: 'var(--fc-sprite-skin-shade, #c98863)',
e: 'var(--fc-sprite-eye, #10131c)',
m: 'var(--fc-sprite-mouth, #80484b)',
h: 'var(--fc-sprite-hat, #ffd84d)',
w: 'var(--fc-sprite-band, #fde68a)',
c: 'var(--fc-sprite-shirt, #00f0ff)',
C: 'var(--fc-sprite-shirt-shade, #0369a1)',
p: 'var(--fc-sprite-pants, #3a1f6e)',
P: 'var(--fc-sprite-pants-shade, #2d1857)',
b: 'var(--fc-sprite-boots, #0f172a)',
}
const colors = computed(() => ({ ...defaultColors, ...(props.theme ?? {}) }))
const pixels = computed(() => {
const out: { x: number; y: number; k: string }[] = []
spriteData.grid.forEach((row: string, y: number) => {
for (let x = 0; x < row.length; x++) {
const k = row[x]
if (k === '.' || !colors.value[k]) continue
out.push({ x, y, k })
}
})
return out
})
</script>
<style scoped>
.fc-sprite { image-rendering: pixelated; display: inline-block; line-height: 0; }
@keyframes fc-sprite-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.fc-sprite--bob { animation: fc-sprite-bob 2.2s steps(2) infinite; }
@keyframes fc-sprite-celebrate {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-6px) rotate(-3deg); }
75% { transform: translateY(-6px) rotate(3deg); }
}
.fc-sprite--celebrate { animation: fc-sprite-celebrate 1s ease-in-out infinite; }
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="fc-hud-stat">
<div class="fc-hud-stat__label">{{ label }}</div>
<div class="fc-hud-stat__value">
<span :style="{ color, textShadow: `0 0 8px ${color}66` }">{{ value }}</span>
<span v-if="sub" class="fc-hud-stat__sub">{{ sub }}</span>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
/** Short uppercase label, ex: "LV", "XP", "STREAK", "META". */
label: string
/** Main value, displayed in pixel font. */
value: string | number
/** Optional fraction-style suffix, ex: "/4900" or "/40%". */
sub?: string
/** CSS color string (use var(--fc-*) tokens). Default = text color. */
color?: string
}>()
</script>
<style scoped>
.fc-hud-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.fc-hud-stat__label {
font-family: var(--fc-font-pixel);
font-size: 7px;
color: var(--fc-text-dim);
letter-spacing: .06em;
}
.fc-hud-stat__value {
display: flex;
align-items: baseline;
gap: 4px;
font-family: var(--fc-font-pixel);
font-size: 13px;
}
.fc-hud-stat__sub {
font-family: var(--fc-font-mono);
font-size: 10px;
color: var(--fc-text-dim);
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<Teleport to="body">
<div v-if="show" class="fc-levelup-overlay" @click="$emit('close')">
<div class="fc-levelup-panel fc-pixel" @click.stop>
<div class="fc-levelup-plus">+1</div>
<div class="fc-levelup-label">LEVEL UP!</div>
<div class="fc-levelup-level">{{ level }}</div>
<button class="fc-btn fc-btn--pink" @click="$emit('close')">OK!</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{ show: boolean; level: number }>()
defineEmits<{ close: [] }>()
</script>
<style scoped>
.fc-levelup-overlay {
position: fixed; inset: 0; z-index: 999;
background: rgba(0,0,0,.75);
display: flex; align-items: center; justify-content: center;
}
.fc-levelup-panel {
background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
border: 2px solid var(--fc-accent);
box-shadow: 0 0 0 1px var(--fc-accent), 0 0 40px rgba(255,45,149,.5);
border-radius: var(--fc-radius);
padding: 40px 48px;
text-align: center;
display: flex; flex-direction: column; gap: 16px; align-items: center;
animation: fc-levelup-in .3s cubic-bezier(.2,1.4,.4,1);
}
@keyframes fc-levelup-in { from { transform: scale(.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.fc-levelup-plus { font-size: 14px; color: var(--fc-gold); text-shadow: 0 0 12px rgba(255,216,77,.8); }
.fc-levelup-label { font-size: 18px; color: var(--fc-accent); text-shadow: 0 0 16px rgba(255,45,149,.8); }
.fc-levelup-level { font-size: 48px; color: var(--fc-gold); text-shadow: 0 0 24px rgba(255,216,77,.9); line-height: 1; }
.fc-btn {
font-family: var(--fc-font-pixel); font-size: 9px; padding: 9px 16px;
background: var(--fc-bg-raised); border: 2px solid var(--fc-accent);
color: var(--fc-text); cursor: pointer; border-radius: 2px;
letter-spacing: .05em; text-transform: uppercase; transition: all .12s;
}
.fc-btn:hover { background: var(--fc-accent); box-shadow: 0 0 16px rgba(255,45,149,.6); }
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="fc-month-switcher">
<button class="fc-month-btn fc-pixel" @click="$emit('prev')"></button>
<span class="fc-month-label fc-pixel">{{ label }}</span>
<button class="fc-month-btn fc-pixel" @click="$emit('next')"></button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ month: string }>()
defineEmits<{ prev: []; next: [] }>()
const label = computed(() => {
const [y, m] = props.month.split('-').map(Number)
const name = new Date(y, m - 1, 1).toLocaleString('pt-BR', { month: 'short' }).toUpperCase()
return `${name} · ${y}`
})
</script>
<style scoped>
.fc-month-switcher {
display: flex; align-items: center; gap: var(--fc-space-2);
}
.fc-month-btn {
background: none; border: 1px solid var(--fc-panel-edge);
color: var(--fc-accent-2); font-size: 14px; cursor: pointer;
padding: 4px 8px; border-radius: var(--fc-radius-sm);
transition: all .12s;
}
.fc-month-btn:hover { border-color: var(--fc-accent-2); box-shadow: 0 0 8px rgba(0,240,255,.3); }
.fc-month-label { font-size: 9px; color: var(--fc-text); min-width: 110px; text-align: center; }
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div
class="fc-panel"
:class="{ 'fc-panel--glow': glow, [`fc-panel--${variant}`]: !!variant }"
>
<template v-if="corners">
<span class="fc-corner fc-corner--tl" />
<span class="fc-corner fc-corner--tr" />
<span class="fc-corner fc-corner--bl" />
<span class="fc-corner fc-corner--br" />
</template>
<header v-if="title || $slots.header" class="fc-panel__header">
<div v-if="title" class="fc-panel__title">:: {{ title }}</div>
<slot name="header" />
</header>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
/** Painel "principal" da rota: borda roxa + halo. Use com parcimônia. */
glow?: boolean
/** Cantos em L decorativos. Reserve pro card de destaque. */
corners?: boolean
/** 'danger' (recorrência ausente, meta falhou) | 'success' */
variant?: 'danger' | 'success'
/** Título estilo HUD; renderizado automaticamente como `:: NOME`. */
title?: string
}>()
</script>
<style scoped>
.fc-panel {
position: relative;
background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
border: 2px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
padding: var(--fc-space-4);
box-shadow: var(--fc-shadow-panel);
}
.fc-panel--glow {
border-color: var(--fc-accent-3);
box-shadow: var(--fc-shadow-panel-glow);
}
.fc-panel--danger {
border-color: var(--fc-red);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.05),
0 0 0 1px var(--fc-red),
0 0 16px rgba(255, 59, 107, .3),
0 8px 24px rgba(0,0,0,.4);
}
.fc-panel--success {
border-color: var(--fc-green);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.05),
0 0 0 1px var(--fc-green),
0 0 16px rgba(57, 255, 122, .25),
0 8px 24px rgba(0,0,0,.4);
}
.fc-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--fc-space-3);
gap: var(--fc-space-2);
}
.fc-panel__title {
font-family: var(--fc-font-pixel);
font-size: 9px;
color: var(--fc-accent-2);
letter-spacing: .04em;
text-transform: uppercase;
}
.fc-corner {
position: absolute;
width: 8px;
height: 8px;
border: 2px solid var(--fc-accent-2);
pointer-events: none;
}
.fc-corner--tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
.fc-corner--tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
.fc-corner--bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
.fc-corner--br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
</style>
+30 -27
View File
@@ -1,42 +1,45 @@
<template> <template>
<div class="xp-bar-wrap"> <div class="fc-bar" :class="[`fc-bar--${size}`, { 'fc-bar--success': success, 'fc-bar--danger': danger }]">
<div class="xp-labels"> <div class="fc-bar__fill" :style="{ width: clamped + '%' }" />
<span class="level-badge">Nv {{ level }}</span>
<span class="xp-text">{{ xp }} / {{ xpToNext }} XP</span>
</div>
<div class="xp-track">
<div class="xp-fill" :style="{ width: pct + '%' }" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps<{ const props = withDefaults(defineProps<{
level: number pct: number
xp: number size?: 'sm' | 'md' | 'lg'
xpToNext: number success?: boolean
}>() danger?: boolean
}>(), { size: 'md' })
const pct = computed(() => Math.min(100, Math.round((props.xp / props.xpToNext) * 100))) const clamped = computed(() => Math.max(0, Math.min(100, props.pct)))
</script> </script>
<style scoped> <style scoped>
.xp-bar-wrap { display: flex; flex-direction: column; gap: 4px; } .fc-bar {
.xp-labels { display: flex; justify-content: space-between; align-items: center; } background: var(--fc-bg);
.level-badge { border: 2px solid var(--fc-panel-edge);
background: #7c3aed; color: #fff; font-size: 0.75rem; position: relative;
font-weight: 700; padding: 2px 8px; border-radius: 4px; overflow: hidden;
} }
.xp-text { font-size: 0.75rem; color: var(--color-text-secondary, #888); } .fc-bar--sm { height: 6px; }
.xp-track { .fc-bar--md { height: 8px; }
height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden; .fc-bar--lg { height: 14px; }
} .fc-bar__fill {
.xp-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #7c3aed, #a855f7); background: linear-gradient(90deg, var(--fc-accent-2), var(--fc-accent-3), var(--fc-accent));
border-radius: 5px; position: relative;
transition: width 0.4s ease; transition: width .6s cubic-bezier(.2,.7,.3,1);
} }
.fc-bar__fill::after {
content: '';
position: absolute; inset: 0;
background-image: repeating-linear-gradient(90deg, rgba(0,0,0,.2) 0 2px, transparent 2px 6px);
}
.fc-bar--success { border-color: var(--fc-green); }
.fc-bar--success .fc-bar__fill { background: linear-gradient(90deg, var(--fc-green), var(--fc-accent-2)); }
.fc-bar--danger { border-color: var(--fc-red); }
.fc-bar--danger .fc-bar__fill { background: linear-gradient(90deg, var(--fc-red), var(--fc-accent)); }
</style> </style>
+1
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import './styles/tokens.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+30 -2
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, computed } from 'vue'
import spriteData from '@/assets/sprite-data.json'
export interface PlayerProfile { export interface PlayerProfile {
id: number id: number
@@ -76,5 +77,32 @@ export const useGameStore = defineStore('game', () => {
await fetchSummary() await fetchSummary()
} }
return { summary, loading, fetchSummary, claimQuest, equipCosmetic } // Merge sprite theme from equipped cosmetics.
// css_data JSON may contain a "sprite_id" matching sprite-data.json cosmetics,
// OR the full theme directly. Falls back to empty (default sprite colors).
const equippedTheme = computed<Record<string, string>>(() => {
if (!summary.value) return {}
const equipped = summary.value.equipped_cosmetics
if (!equipped.length) return {}
return equipped.reduce((acc, c) => {
try {
const data = JSON.parse(c.css_data)
// If css_data has sprite_id, look up in sprite-data.json
if (data.sprite_id) {
const spr = (spriteData.cosmetics as any[]).find(x => x.id === data.sprite_id)
if (spr?.theme) return { ...acc, ...spr.theme }
}
// Otherwise treat css_data itself as theme (keys h/c/p/b etc.)
const themeKeys = ['h','w','c','C','p','P','b']
const direct = Object.fromEntries(
Object.entries(data).filter(([k]) => themeKeys.includes(k))
) as Record<string, string>
return { ...acc, ...direct }
} catch { return acc }
}, {} as Record<string, string>)
})
const justLeveledUp = ref(false)
return { summary, loading, equippedTheme, justLeveledUp, fetchSummary, claimQuest, equipCosmetic }
}) })
+236
View File
@@ -0,0 +1,236 @@
/* Financeiro Carvalho · Arcade Neon
* Drop this into src/styles/tokens.css and import once in main.ts:
* import './styles/tokens.css'
*
* All other components MUST consume var(--fc-*) instead of literal hex.
*/
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap');
:root {
/* ─────────────── color tokens ─────────────── */
--fc-bg: #0a0418;
--fc-bg-raised: #150827;
--fc-bg-panel: #1c0d33;
--fc-panel-edge: #2d1857;
--fc-line: #3a1f6e;
--fc-text: #f4eaff;
--fc-text-dim: #8b75b8;
--fc-accent: #ff2d95; /* magenta — alerts, "today", primary CTA accent */
--fc-accent-2: #00f0ff; /* cyan — links, nav, "weekly" */
--fc-accent-3: #a855f7; /* purple — structure, glow, default button */
--fc-gold: #ffd84d; /* XP, achievements, "monthly" */
--fc-green: #39ff7a; /* goal hit, positive value */
--fc-red: #ff3b6b; /* goal missed, critical alert */
/* rgb fragments for shadows / alphas (no color-mix support? use these) */
--fc-accent-rgb: 255 45 149;
--fc-accent-2-rgb: 0 240 255;
--fc-accent-3-rgb: 168 85 247;
--fc-gold-rgb: 255 216 77;
--fc-green-rgb: 57 255 122;
--fc-red-rgb: 255 59 107;
/* ─────────────── sprite color slots ─────────────── */
--fc-sprite-outline: #10131c;
--fc-sprite-skin: #f3c79b;
--fc-sprite-skin-shade: #c98863;
--fc-sprite-eye: #10131c;
--fc-sprite-mouth: #80484b;
--fc-sprite-hat: #ffd84d;
--fc-sprite-band: #fde68a;
--fc-sprite-shirt: #00f0ff;
--fc-sprite-shirt-shade: #0369a1;
--fc-sprite-pants: #3a1f6e;
--fc-sprite-pants-shade: #2d1857;
--fc-sprite-boots: #0f172a;
/* ─────────────── sizing ─────────────── */
--fc-radius-sm: 2px;
--fc-radius: 4px;
--fc-radius-lg: 6px;
--fc-space-1: 4px;
--fc-space-2: 8px;
--fc-space-3: 12px;
--fc-space-4: 16px;
--fc-space-5: 24px;
/* ─────────────── shadows ─────────────── */
--fc-shadow-panel:
inset 0 1px 0 rgba(255,255,255,.05),
0 0 0 1px rgba(0,0,0,.4),
0 8px 24px rgba(0,0,0,.4);
--fc-shadow-panel-glow:
inset 0 1px 0 rgba(255,255,255,.05),
0 0 0 1px var(--fc-accent-3),
0 0 24px rgba(168,85,247,.25),
0 8px 24px rgba(0,0,0,.5);
/* ─────────────── fonts ─────────────── */
--fc-font-pixel: 'Press Start 2P', monospace;
--fc-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--fc-font-body: 'Inter', system-ui, sans-serif;
}
/* Global reset for the app root */
html, body {
margin: 0;
padding: 0;
background: var(--fc-bg);
color: var(--fc-text);
font-family: var(--fc-font-body);
}
* { box-sizing: border-box; }
/* Util classes */
.fc-pixel { font-family: var(--fc-font-pixel); letter-spacing: .02em; }
.fc-mono { font-family: var(--fc-font-mono); }
.fc-body { font-family: var(--fc-font-body); }
.fc-text-dim { color: var(--fc-text-dim); }
.fc-text-accent { color: var(--fc-accent); }
.fc-text-accent2 { color: var(--fc-accent-2); }
.fc-text-accent3 { color: var(--fc-accent-3); }
.fc-text-gold { color: var(--fc-gold); }
.fc-text-green { color: var(--fc-green); }
.fc-text-red { color: var(--fc-red); }
.fc-glow-cyan { text-shadow: 0 0 8px rgba(0,240,255,.6); }
.fc-glow-magenta { text-shadow: 0 0 8px rgba(255,45,149,.6); }
.fc-glow-purple { text-shadow: 0 0 8px rgba(168,85,247,.6); }
.fc-glow-gold { text-shadow: 0 0 8px rgba(255,216,77,.6); }
.fc-glow-green { text-shadow: 0 0 8px rgba(57,255,122,.6); }
.fc-glow-red { text-shadow: 0 0 8px rgba(255,59,107,.6); }
/* App shell — wrap your <router-view> in <div class="fc-app"> */
.fc-app {
background:
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(168,85,247,.18), transparent 70%),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(255,45,149,.12), transparent 60%),
var(--fc-bg);
position: relative;
min-height: 100vh;
overflow-x: hidden;
}
.fc-app::before {
content: '';
position: absolute; inset: 0;
background-image: repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px);
pointer-events: none;
z-index: 2;
}
.fc-app::after {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(var(--fc-line) 1px, transparent 1px),
linear-gradient(90deg, var(--fc-line) 1px, transparent 1px);
background-size: 32px 32px;
opacity: .12;
pointer-events: none;
}
.fc-app > * { position: relative; z-index: 1; }
/* Blinking utility for critical alerts (recurrence missing, etc.) */
@keyframes fc-blink { 0%,49% { opacity: 1 } 50%,100% { opacity: 0.2 } }
.fc-blink { animation: fc-blink 1.2s steps(1) infinite; }
/* ── Form elements ───────────────────────────────────────────── */
.fc-input,
.fc-select {
font-family: var(--fc-font-mono);
font-size: 12px;
padding: 8px 10px;
background: var(--fc-bg);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
color: var(--fc-text);
outline: none;
transition: border-color .15s, box-shadow .15s;
width: 100%;
}
.fc-input::placeholder { color: var(--fc-text-dim); }
.fc-input:focus,
.fc-select:focus {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.fc-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238b75b8'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
.fc-select option { background: var(--fc-bg-panel); color: var(--fc-text); }
.fc-label {
font-family: var(--fc-font-mono);
font-size: 10px;
color: var(--fc-text-dim);
display: flex;
flex-direction: column;
gap: 4px;
}
/* ── Buttons ─────────────────────────────────────────────────── */
.fc-btn {
font-family: var(--fc-font-pixel);
font-size: 8px;
letter-spacing: .04em;
padding: 8px 14px;
border: 2px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
background: var(--fc-bg-raised);
color: var(--fc-text);
cursor: pointer;
transition: border-color .12s, box-shadow .12s, background .12s;
white-space: nowrap;
}
.fc-btn:disabled { opacity: .45; cursor: not-allowed; }
.fc-btn--primary {
border-color: var(--fc-accent-3);
color: var(--fc-accent-3);
}
.fc-btn--primary:not(:disabled):hover {
background: var(--fc-accent-3);
color: var(--fc-bg);
box-shadow: 0 0 14px rgba(168,85,247,.5);
}
.fc-btn--pink {
border-color: var(--fc-accent);
color: var(--fc-accent);
}
.fc-btn--pink:not(:disabled):hover {
background: var(--fc-accent);
color: #fff;
box-shadow: 0 0 14px rgba(255,45,149,.5);
}
.fc-btn--ghost {
border-color: transparent;
color: var(--fc-text-dim);
}
.fc-btn--ghost:hover { color: var(--fc-text); border-color: var(--fc-panel-edge); }
.fc-btn--danger {
border-color: var(--fc-red);
color: var(--fc-red);
}
.fc-btn--danger:not(:disabled):hover {
background: var(--fc-red);
color: #fff;
box-shadow: 0 0 14px rgba(255,59,107,.5);
}
.fc-btn--sm {
font-size: 7px;
padding: 5px 10px;
}
+164 -69
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAccountsStore, type AccountInput } from '@/stores/accounts' import { useAccountsStore, type AccountInput } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useAccountsStore() const store = useAccountsStore()
onMounted(() => store.fetchAll()) onMounted(() => store.fetchAll())
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
@@ -64,93 +64,188 @@ async function remove(id: number, name: string) {
function fmt(v: number) { function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
} }
function totalBalance() {
return store.accounts.reduce((s, a) => s + a.balance, 0)
}
</script> </script>
<template> <template>
<div class="page"> <div class="fc-view">
<h1>Contas Bancárias</h1> <span class="fc-pixel fc-view__title">:: CONTAS BANCÁRIAS</span>
<form class="form-card" @submit.prevent="submit"> <!-- Form -->
<h2>{{ editId !== null ? 'Editar conta' : 'Nova conta' }}</h2> <NeonPanel :title="editId !== null ? 'EDITAR CONTA' : 'NOVA CONTA'">
<div class="form-row"> <form class="fc-acc-form" @submit.prevent="submit">
<input v-model="form.name" placeholder="Ex: Nubank Corrente" required class="input-name" /> <div class="fc-acc-form__row">
<select v-model="form.type" class="input-sm"> <input v-model="form.name" placeholder="Ex: Nubank Corrente" required class="fc-input fc-acc-form__name" />
<option value="checking">Conta Corrente</option> <select v-model="form.type" class="fc-select fc-acc-form__type">
<option value="savings">Poupança</option> <option value="checking">Conta Corrente</option>
<option value="investment">Investimento</option> <option value="savings">Poupança</option>
<option value="credit">Cartão de Crédito</option> <option value="investment">Investimento</option>
</select> <option value="credit">Cartão de Crédito</option>
<input v-model="initialRaw" placeholder="Saldo inicial (ex: 1.500,00)" class="input-sm input-amount" /> </select>
<input
v-model="initialRaw"
placeholder="Saldo inicial (ex: 1.500,00)"
class="fc-input fc-acc-form__balance"
/>
</div>
<p v-if="formError" class="fc-acc-form__error fc-mono">{{ formError }}</p>
<div class="fc-acc-form__actions">
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
</div>
</form>
</NeonPanel>
<!-- Summary -->
<NeonPanel v-if="store.accounts.length > 0" title="PATRIMÔNIO TOTAL">
<div class="fc-acc-total">
<span class="fc-mono fc-acc-total__value" :class="totalBalance() >= 0 ? 'fc-text-green' : 'fc-text-red'">
{{ fmt(totalBalance()) }}
</span>
<span class="fc-mono fc-acc-total__sub">{{ store.accounts.length }} conta{{ store.accounts.length !== 1 ? 's' : '' }}</span>
</div> </div>
<p v-if="formError" class="form-error">{{ formError }}</p> </NeonPanel>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ editId !== null ? 'Salvar' : 'Adicionar' }}</button>
<button v-if="editId !== null" type="button" class="btn btn-ghost" @click="cancelEdit">Cancelar</button>
</div>
</form>
<p v-if="store.loading">Carregando</p> <!-- Account list -->
<NeonPanel title="CONTAS">
<div v-else> <p v-if="store.loading" class="fc-acc-loading fc-mono">carregando...</p>
<div class="total-bar" v-if="store.accounts.length > 0"> <ul v-else class="fc-acc-list">
<span>Patrimônio total</span>
<strong>{{ fmt(store.accounts.reduce((s, a) => s + a.balance, 0)) }}</strong>
</div>
<ul class="account-list">
<li <li
v-for="a in store.accounts" v-for="a in store.accounts"
:key="a.id" :key="a.id"
class="account-item" class="fc-acc-item"
:class="{ editing: editId === a.id }" :class="{ 'fc-acc-item--editing': editId === a.id }"
> >
<div class="account-info"> <div class="fc-acc-item__info">
<strong>{{ a.name }}</strong> <span class="fc-body fc-acc-item__name">{{ a.name }}</span>
<span class="account-meta">{{ typeLabels[a.type] }} · Saldo inicial {{ fmt(a.initial_balance) }}</span> <span class="fc-mono fc-acc-item__meta">
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
</span>
</div> </div>
<div class="account-right"> <div class="fc-acc-item__right">
<span class="account-balance" :class="a.balance >= 0 ? 'positive' : 'negative'"> <span class="fc-mono fc-acc-item__balance" :class="a.balance >= 0 ? 'fc-text-green' : 'fc-text-red'">
{{ fmt(a.balance) }} {{ fmt(a.balance) }}
</span> </span>
<div class="item-actions"> <div class="fc-acc-item__actions">
<button class="btn btn-sm" @click="startEdit(a.id)">Editar</button> <button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(a.id)">EDITAR</button>
<button class="btn btn-sm btn-danger" @click="remove(a.id, a.name)">Excluir</button> <button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(a.id, a.name)"></button>
</div> </div>
</div> </div>
</li> </li>
<li v-if="store.accounts.length === 0" class="empty">Nenhuma conta cadastrada</li> <li v-if="store.accounts.length === 0" class="fc-acc-empty fc-mono"> nenhuma conta cadastrada </li>
</ul> </ul>
</div> </NeonPanel>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { max-width: 640px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; } .fc-view {
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; } max-width: 640px;
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; } margin: 0 auto;
.form-card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } padding: var(--fc-space-4);
.form-row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } display: flex;
.input-name { flex: 1; min-width: 160px; padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } flex-direction: column;
.input-sm { padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } gap: var(--fc-space-4);
.input-amount { width: 140px; } padding-bottom: 96px;
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0 0; } }
.form-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
.btn { padding: 0.4rem 1rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 0.875rem; background: #fff; } .fc-view__title {
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; } font-size: 11px;
.btn-ghost { background: transparent; } color: var(--fc-accent-2);
.btn-sm { padding: 0.25rem 0.6rem; } }
.btn-danger { color: #dc2626; border-color: #fca5a5; }
.total-bar { display: flex; justify-content: space-between; align-items: center; background: #1e293b; color: #f8fafc; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; font-size: 0.9rem; } .fc-acc-form__row {
.total-bar strong { font-size: 1.1rem; } display: flex;
.account-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; } gap: var(--fc-space-2);
.account-item { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; flex-wrap: wrap; gap: 0.5rem; } flex-wrap: wrap;
.account-item.editing { border-color: #4f46e5; box-shadow: 0 0 0 2px #e0e7ff; } align-items: flex-end;
.account-info { display: flex; flex-direction: column; gap: 0.2rem; } }
.account-meta { font-size: 0.8rem; color: #6b7280; }
.account-right { display: flex; align-items: center; gap: 1rem; } .fc-acc-form__name { flex: 1; min-width: 160px; }
.account-balance { font-size: 1.1rem; font-weight: 700; } .fc-acc-form__type { width: 160px; }
.positive { color: #059669; } .fc-acc-form__balance { width: 160px; }
.negative { color: #dc2626; }
.item-actions { display: flex; gap: 0.4rem; } .fc-acc-form__error {
.empty { color: #9ca3af; text-align: center; padding: 2rem; } color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-acc-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* Total */
.fc-acc-total {
display: flex;
align-items: baseline;
gap: var(--fc-space-3);
}
.fc-acc-total__value {
font-size: 24px;
font-weight: 700;
}
.fc-acc-total__sub {
font-size: 11px;
color: var(--fc-text-dim);
}
/* List */
.fc-acc-loading, .fc-acc-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.fc-acc-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.fc-acc-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 12px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
flex-wrap: wrap;
transition: border-color .15s;
}
.fc-acc-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.fc-acc-item__info {
display: flex;
flex-direction: column;
gap: 4px;
}
.fc-acc-item__name { font-size: 14px; font-weight: 500; }
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-acc-item__right {
display: flex;
align-items: center;
gap: var(--fc-space-3);
}
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
</style> </style>
+155 -99
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useCategoriesStore } from '@/stores/categories' import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useCategoriesStore() const store = useCategoriesStore()
onMounted(() => store.fetchAll()) onMounted(() => store.fetchAll())
const form = ref({ name: '', color: '#6B7280' }) const form = ref({ name: '', color: '#a855f7' })
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
@@ -17,7 +18,7 @@ function startEdit(id: number, name: string, color: string) {
function cancelEdit() { function cancelEdit() {
editId.value = null editId.value = null
form.value = { name: '', color: '#6B7280' } form.value = { name: '', color: '#a855f7' }
formError.value = null formError.value = null
} }
@@ -29,7 +30,7 @@ async function submit() {
cancelEdit() cancelEdit()
} else { } else {
await store.create(form.value.name, form.value.color) await store.create(form.value.name, form.value.color)
form.value = { name: '', color: '#6B7280' } form.value = { name: '', color: '#a855f7' }
} }
} catch (e: any) { } catch (e: any) {
formError.value = e.message formError.value = e.message
@@ -38,122 +39,177 @@ async function submit() {
async function remove(id: number, name: string) { async function remove(id: number, name: string) {
if (!confirm(`Excluir a categoria "${name}"?`)) return if (!confirm(`Excluir a categoria "${name}"?`)) return
try { try { await store.remove(id) } catch (e: any) { alert(e.message) }
await store.remove(id)
} catch (e: any) {
alert(e.message)
}
} }
</script> </script>
<template> <template>
<div class="page"> <div class="fc-view">
<h1>Categorias</h1> <span class="fc-pixel fc-view__title">:: CATEGORIAS</span>
<form class="form-card" @submit.prevent="submit"> <!-- Form -->
<h2>{{ editId !== null ? 'Editar categoria' : 'Nova categoria' }}</h2> <NeonPanel :title="editId !== null ? 'EDITAR CATEGORIA' : 'NOVA CATEGORIA'">
<div class="field-row"> <form class="fc-cat-form" @submit.prevent="submit">
<input <div class="fc-cat-form__row">
v-model="form.name" <input
placeholder="Nome da categoria" v-model="form.name"
required placeholder="Nome da categoria"
class="input-name" required
/> class="fc-input fc-cat-form__name"
<div class="color-field"> />
<input type="color" v-model="form.color" class="input-color" /> <div class="fc-cat-form__color">
<span class="color-hex">{{ form.color }}</span> <input type="color" v-model="form.color" class="fc-color-input" />
<span class="fc-mono fc-cat-form__hex">{{ form.color }}</span>
</div>
</div> </div>
</div> <p v-if="formError" class="fc-cat-form__error fc-mono">{{ formError }}</p>
<p v-if="formError" class="form-error">{{ formError }}</p> <div class="fc-cat-form__actions">
<div class="form-actions"> <button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'CRIAR' }}</button>
<button type="submit" class="btn btn-primary"> <button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
{{ editId !== null ? 'Salvar' : 'Criar' }}
</button>
<button v-if="editId !== null" type="button" class="btn btn-ghost" @click="cancelEdit">
Cancelar
</button>
</div>
</form>
<p v-if="store.loading">Carregando</p>
<p v-else-if="store.error" class="form-error">{{ store.error }}</p>
<ul v-else class="category-list">
<li
v-for="cat in store.categories"
:key="cat.id"
class="category-item"
:class="{ editing: editId === cat.id }"
>
<span class="color-dot" :style="{ background: cat.color }" />
<span class="cat-name">{{ cat.name }}</span>
<span v-if="cat.is_default" class="badge">padrão</span>
<div class="cat-actions">
<button class="btn btn-sm" @click="startEdit(cat.id, cat.name, cat.color)">Editar</button>
<button
v-if="!cat.is_default"
class="btn btn-sm btn-danger"
@click="remove(cat.id, cat.name)"
>
Excluir
</button>
</div> </div>
</li> </form>
</ul> </NeonPanel>
<!-- List -->
<NeonPanel title="CATEGORIAS">
<p v-if="store.loading" class="fc-cat-loading fc-mono">carregando...</p>
<p v-else-if="store.error" class="fc-mono fc-text-red">{{ store.error }}</p>
<ul v-else class="fc-cat-list">
<li
v-for="cat in store.categories"
:key="cat.id"
class="fc-cat-item"
:class="{ 'fc-cat-item--editing': editId === cat.id }"
>
<span class="fc-cat-item__dot" :style="{ background: cat.color }" />
<span class="fc-body fc-cat-item__name">{{ cat.name }}</span>
<span v-if="cat.is_default" class="fc-pixel fc-cat-item__default">PADRÃO</span>
<div class="fc-cat-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(cat.id, cat.name, cat.color)">EDITAR</button>
<button
v-if="!cat.is_default"
class="fc-btn fc-btn--sm fc-btn--danger"
@click="remove(cat.id, cat.name)"
></button>
</div>
</li>
<li v-if="store.categories.length === 0" class="fc-cat-empty fc-mono"> nenhuma categoria </li>
</ul>
</NeonPanel>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { .fc-view {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1rem; padding: var(--fc-space-4);
font-family: sans-serif; display: flex;
flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
} }
h1 { margin-bottom: 1.25rem; font-size: 1.5rem; }
h2 { margin: 0 0 0.75rem; font-size: 1rem; font-weight: 600; }
.form-card { .fc-view__title {
background: #f9fafb; font-size: 11px;
border: 1px solid #e5e7eb; color: var(--fc-accent-2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
} }
.field-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.input-name { flex: 1; min-width: 0; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.95rem; }
.color-field { display: flex; align-items: center; gap: 0.25rem; }
.input-color { width: 2.5rem; height: 2.5rem; border: none; cursor: pointer; border-radius: 4px; padding: 0; }
.color-hex { font-size: 0.8rem; color: #6b7280; width: 4rem; }
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0 0; }
.form-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
.btn { .fc-cat-form__row {
padding: 0.4rem 1rem; display: flex;
border: 1px solid #d1d5db; gap: var(--fc-space-2);
border-radius: 6px; align-items: center;
cursor: pointer; flex-wrap: wrap;
font-size: 0.875rem;
background: #fff;
} }
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.btn-ghost { background: transparent; }
.btn-danger { color: #dc2626; border-color: #fca5a5; }
.btn-sm { padding: 0.25rem 0.6rem; }
.category-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; } .fc-cat-form__name { flex: 1; min-width: 0; }
.category-item {
.fc-cat-form__color {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: var(--fc-space-1);
padding: 0.65rem 0.75rem; flex-shrink: 0;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
} }
.category-item.editing { border-color: #4f46e5; box-shadow: 0 0 0 2px #e0e7ff; }
.color-dot { width: 1rem; height: 1rem; border-radius: 50%; flex-shrink: 0; } .fc-color-input {
.cat-name { flex: 1; font-size: 0.95rem; } width: 36px;
.badge { font-size: 0.7rem; background: #f3f4f6; color: #6b7280; padding: 0.15rem 0.4rem; border-radius: 4px; } height: 36px;
.cat-actions { display: flex; gap: 0.4rem; } border: 2px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
padding: 2px;
background: var(--fc-bg);
cursor: pointer;
}
.fc-cat-form__hex {
font-size: 11px;
color: var(--fc-text-dim);
width: 56px;
}
.fc-cat-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-cat-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* List */
.fc-cat-loading, .fc-cat-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.fc-cat-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.fc-cat-item {
display: flex;
align-items: center;
gap: var(--fc-space-3);
padding: 10px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
transition: border-color .15s;
}
.fc-cat-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.fc-cat-item__dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.fc-cat-item__name {
flex: 1;
font-size: 13px;
}
.fc-cat-item__default {
font-size: 6px;
color: var(--fc-text-dim);
background: rgba(139,117,184,.15);
padding: 3px 6px;
border-radius: 2px;
}
.fc-cat-item__actions { display: flex; gap: var(--fc-space-1); }
</style> </style>
+432 -165
View File
@@ -1,125 +1,155 @@
<template> <template>
<div class="character-page"> <div class="fc-character">
<h1 class="page-title">Personagem</h1> <div v-if="store.loading" class="fc-character__loading fc-pixel">CARREGANDO...</div>
<div v-if="store.loading" class="loading">Carregando...</div>
<template v-else-if="store.summary"> <template v-else-if="store.summary">
<!-- Profile panel --> <!-- HERO -->
<div class="profile-panel card"> <div class="fc-character__hero">
<div class="character-area"> <!-- sprite + stats -->
<CharacterWidget <NeonPanel glow corners class="fc-character__sprite-panel">
:level="store.summary.profile.level" <div class="fc-character__sprite-wrap">
:cosmetics="allCosmetics" <CharacterSprite
:celebrate="justLeveledUp" :scale="6"
/> :theme="store.equippedTheme"
</div> :celebrate="showLevelUp"
<div class="profile-info"> bob
<h2>Nível {{ store.summary.profile.level }}</h2> />
<XPBar </div>
:level="store.summary.profile.level" <div class="fc-character__hero-stats">
:xp="store.summary.profile.xp" <div class="fc-character__lvrow">
:xp-to-next="store.summary.profile.xp_to_next" <span class="fc-pixel fc-character__lv-label">LV</span>
/> <span class="fc-pixel fc-character__lv-num">{{ store.summary.profile.level }}</span>
<p class="xp-hint">Continue registrando transações para ganhar XP!</p> </div>
<XPBar :pct="xpPct" size="lg" />
<span class="fc-character__xp-hint fc-mono">
{{ store.summary.profile.xp }} / {{ store.summary.profile.xp_to_next }} XP
</span>
</div>
</NeonPanel>
<!-- right column: quests -->
<div class="fc-character__side">
<NeonPanel title="QUESTS ATIVAS" class="fc-character__quests-panel">
<div v-if="!store.summary.active_quests.length" class="fc-empty fc-mono">
nenhuma quest ativa
</div>
<div v-else class="fc-character__quest-list">
<div
v-for="q in store.summary.active_quests"
:key="q.id"
class="fc-character__quest"
:class="{ 'fc-character__quest--claimed': q.claimed }"
>
<div class="fc-character__quest-top">
<span class="fc-character__quest-chip fc-pixel" :class="`fc-character__quest-chip--${q.quest_type}`">
{{ typeLabel(q.quest_type) }}
</span>
<span class="fc-character__quest-xp fc-pixel">+{{ q.xp_reward }} XP</span>
</div>
<p class="fc-character__quest-title fc-body">{{ q.title }}</p>
<div class="fc-character__quest-progress">
<template v-if="q.target_count !== null">
<XPBar :pct="countPct(q)" size="sm" />
<span class="fc-character__prog-label fc-mono">{{ q.current_count }} / {{ q.target_count }}</span>
</template>
<template v-else-if="q.target_pct !== null">
<XPBar :pct="Math.min(100, q.current_pct)" size="sm" />
<span class="fc-character__prog-label fc-mono">{{ q.current_pct.toFixed(1) }}% / {{ q.target_pct }}%</span>
</template>
</div>
<button
v-if="q.completed && !q.claimed"
class="fc-character__btn-claim fc-pixel"
@click="claim(q)"
>RESGATAR</button>
<span v-else-if="q.claimed" class="fc-character__claimed fc-mono"> RESGATADO</span>
</div>
</div>
</NeonPanel>
</div> </div>
</div> </div>
<!-- Quests --> <!-- CONQUISTAS -->
<section class="section"> <NeonPanel title="CONQUISTAS">
<h2 class="section-title">Quests Ativas</h2> <div class="fc-character__ach-grid">
<div v-if="store.summary.active_quests.length === 0" class="empty">Nenhuma quest ativa.</div>
<div class="quest-list">
<div
v-for="q in store.summary.active_quests"
:key="q.id"
class="quest-card card"
:class="{ completed: q.completed, claimed: q.claimed }"
>
<div class="quest-header">
<span class="quest-type-badge" :class="q.quest_type">{{ typeLabel(q.quest_type) }}</span>
<span class="quest-xp">+{{ q.xp_reward }} XP</span>
</div>
<p class="quest-title">{{ q.title }}</p>
<div class="quest-progress">
<template v-if="q.target_count !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: countPct(q) + '%' }" />
</div>
<span class="progress-label">{{ q.current_count }} / {{ q.target_count }}</span>
</template>
<template v-else-if="q.target_pct !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: Math.min(100, q.current_pct) + '%' }" />
</div>
<span class="progress-label">{{ q.current_pct.toFixed(1) }}% / {{ q.target_pct }}%</span>
</template>
</div>
<button
v-if="q.completed && !q.claimed"
class="btn-claim"
@click="claim(q.id)"
>Resgatar</button>
<span v-else-if="q.claimed" class="claimed-badge"> Resgatado</span>
</div>
</div>
</section>
<!-- Achievements -->
<section class="section">
<h2 class="section-title">Conquistas</h2>
<div class="achievement-list">
<div <div
v-for="a in achievements" v-for="a in achievements"
:key="a.id" :key="a.id"
class="achievement-card card" class="fc-character__ach"
:class="{ earned: a.earned }" :class="{ 'fc-character__ach--earned': a.earned }"
> >
<span class="achievement-icon">{{ a.icon || '🏆' }}</span> <span class="fc-character__ach-icon">{{ a.icon || '' }}</span>
<div class="achievement-info"> <div class="fc-character__ach-body">
<p class="achievement-title">{{ a.title }}</p> <p class="fc-character__ach-title fc-pixel">{{ a.title }}</p>
<p class="achievement-desc">{{ a.description }}</p> <p class="fc-character__ach-desc fc-mono">{{ a.description }}</p>
<span v-if="a.earned" class="earned-tag">+{{ a.xp_reward }} XP conquistado</span> <span v-if="a.earned" class="fc-character__ach-tag fc-pixel">+{{ a.xp_reward }} XP</span>
<span v-else class="locked-tag">Bloqueado</span> <span v-else class="fc-character__locked fc-mono">🔒 BLOQUEADO</span>
</div> </div>
</div> </div>
</div> </div>
</section> </NeonPanel>
<!-- Cosmetics --> <!-- COSMÉTICOS -->
<section class="section"> <NeonPanel title="COSMÉTICOS">
<h2 class="section-title">Cosméticos</h2> <div class="fc-character__cos-grid">
<div class="cosmetic-list">
<div <div
v-for="c in allCosmetics" v-for="c in allCosmetics"
:key="c.id" :key="c.id"
class="cosmetic-card card" class="fc-character__cos"
:class="{ equipped: c.equipped, locked: !c.unlocked }" :class="{
'fc-character__cos--equipped': c.equipped,
'fc-character__cos--locked': !c.unlocked,
}"
@click="c.unlocked && !c.equipped && equip(c.id)" @click="c.unlocked && !c.equipped && equip(c.id)"
> >
<div class="cosmetic-swatch" :style="swatchStyle(c)" /> <div class="fc-character__cos-img-wrap">
<p class="cosmetic-name">{{ c.name }}</p> <img
<p class="cosmetic-unlock">Nv {{ c.unlock_level }}</p> v-if="cosmeticImg(c)"
<span v-if="c.equipped" class="equipped-tag">Equipado</span> :src="cosmeticImg(c)"
<span v-else-if="!c.unlocked" class="locked-tag">🔒</span> class="fc-character__cos-img"
:alt="c.name"
/>
<span v-else class="fc-character__cos-placeholder">?</span>
</div>
<p class="fc-character__cos-name fc-pixel">{{ c.name }}</p>
<p class="fc-character__cos-lv fc-mono">NV {{ c.unlock_level }}</p>
<span v-if="c.equipped" class="fc-character__cos-equipped fc-pixel">EQUIPADO</span>
<span v-else-if="!c.unlocked" class="fc-character__cos-lock fc-mono">🔒</span>
</div> </div>
</div> </div>
</section> </NeonPanel>
</template> </template>
<LevelUpModal
:show="showLevelUp"
:level="store.summary?.profile.level ?? 0"
@close="showLevelUp = false"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useGameStore, type Achievement, type Cosmetic } from '@/stores/game' import { useGameStore, type Achievement, type Cosmetic } from '@/stores/game'
import CharacterWidget from '@/components/CharacterWidget.vue' import CharacterSprite from '@/components/CharacterSprite.vue'
import XPBar from '@/components/XPBar.vue' import XPBar from '@/components/XPBar.vue'
import NeonPanel from '@/components/NeonPanel.vue'
import LevelUpModal from '@/components/LevelUpModal.vue'
const store = useGameStore() const store = useGameStore()
const justLeveledUp = ref(false) const showLevelUp = ref(false)
const achievements = ref<Achievement[]>([]) const achievements = ref<Achievement[]>([])
const allCosmetics = ref<Cosmetic[]>([]) const allCosmetics = ref<Cosmetic[]>([])
const spriteImages = Object.fromEntries(
Object.entries(
import.meta.glob('@/assets/sprites/*-4x.png', { eager: true, import: 'default' })
).map(([k, v]) => [
k.replace(/.*sprites\/manoel-(.+)-4x\.png$/, '$1'),
v as string,
])
)
onMounted(async () => { onMounted(async () => {
await store.fetchSummary() await store.fetchSummary()
const [achRes, cosRes] = await Promise.all([ const [achRes, cosRes] = await Promise.all([
@@ -130,8 +160,14 @@ onMounted(async () => {
if (cosRes.ok) allCosmetics.value = await cosRes.json() if (cosRes.ok) allCosmetics.value = await cosRes.json()
}) })
const xpPct = computed(() => {
const p = store.summary?.profile
if (!p || !p.xp_to_next) return 0
return Math.min(100, Math.round((p.xp / p.xp_to_next) * 100))
})
function typeLabel(t: string) { function typeLabel(t: string) {
return { daily: 'Diária', weekly: 'Semanal', monthly: 'Mensal' }[t] ?? t return { daily: 'DIÁRIA', weekly: 'SEMANAL', monthly: 'MENSAL' }[t] ?? t.toUpperCase()
} }
function countPct(q: { current_count: number; target_count: number | null }) { function countPct(q: { current_count: number; target_count: number | null }) {
@@ -139,10 +175,10 @@ function countPct(q: { current_count: number; target_count: number | null }) {
return Math.min(100, Math.round((q.current_count / q.target_count) * 100)) return Math.min(100, Math.round((q.current_count / q.target_count) * 100))
} }
async function claim(id: number) { async function claim(q: { id: number }) {
await store.claimQuest(id) const prevLevel = store.summary?.profile.level ?? 0
justLeveledUp.value = true await store.claimQuest(q.id)
setTimeout(() => (justLeveledUp.value = false), 2000) if ((store.summary?.profile.level ?? 0) > prevLevel) showLevelUp.value = true
} }
async function equip(id: number) { async function equip(id: number) {
@@ -151,93 +187,324 @@ async function equip(id: number) {
if (res.ok) allCosmetics.value = await res.json() if (res.ok) allCosmetics.value = await res.json()
} }
function swatchStyle(c: Cosmetic) { function cosmeticImg(c: Cosmetic): string {
try { try {
const data = JSON.parse(c.css_data) const data = JSON.parse(c.css_data)
return { background: data.color ?? '#ccc' } if (data.sprite_id && spriteImages[data.sprite_id]) return spriteImages[data.sprite_id]
} catch { } catch { /* ignore */ }
return { background: '#ccc' } return spriteImages['default'] ?? ''
}
} }
</script> </script>
<style scoped> <style scoped>
.character-page { max-width: 860px; margin: 0 auto; padding: 1.5rem; } .fc-character {
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; } max-width: 900px;
.loading { text-align: center; padding: 2rem; color: #888; } margin: 0 auto;
padding: var(--fc-space-4);
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 1rem;
}
.profile-panel {
display: flex; display: flex;
gap: 1.5rem; flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.fc-character__loading {
text-align: center;
padding: 4rem;
color: var(--fc-text-dim);
font-size: 10px;
}
/* ── HERO ────────────────────────────────────────────── */
.fc-character__hero {
display: grid;
grid-template-columns: 260px 1fr;
gap: var(--fc-space-4);
align-items: start;
}
.fc-character__sprite-panel {
display: flex;
flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 2rem; gap: var(--fc-space-4);
} }
.character-area { flex-shrink: 0; }
.profile-info { flex: 1; }
.profile-info h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; }
.xp-hint { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
.section { margin-bottom: 2rem; } .fc-character__sprite-wrap {
.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; } display: flex;
.empty { color: #888; font-size: 0.9rem; } align-items: center;
justify-content: center;
padding: var(--fc-space-2) 0;
}
.quest-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; } .fc-character__hero-stats {
.quest-card { display: flex; flex-direction: column; gap: 0.5rem; } width: 100%;
.quest-card.claimed { opacity: 0.6; } display: flex;
.quest-header { display: flex; justify-content: space-between; align-items: center; } flex-direction: column;
.quest-type-badge { align-items: center;
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; gap: var(--fc-space-2);
padding: 2px 6px; border-radius: 4px;
} }
.quest-type-badge.daily { background: #fef3c7; color: #92400e; }
.quest-type-badge.weekly { background: #dbeafe; color: #1e40af; }
.quest-type-badge.monthly { background: #ede9fe; color: #5b21b6; }
.quest-xp { font-size: 0.75rem; font-weight: 600; color: #7c3aed; }
.quest-title { font-size: 0.9rem; font-weight: 500; }
.quest-progress { display: flex; flex-direction: column; gap: 4px; }
.progress-track { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.3s; }
.progress-label { font-size: 0.7rem; color: #888; }
.btn-claim {
background: #7c3aed; color: #fff; border: none; border-radius: 6px;
padding: 6px 12px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
}
.btn-claim:hover { background: #6d28d9; }
.claimed-badge { font-size: 0.75rem; color: #16a34a; font-weight: 600; }
.achievement-list { .fc-character__lvrow {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.75rem; display: flex;
align-items: baseline;
gap: 6px;
} }
.achievement-card {
display: flex; gap: 0.75rem; align-items: flex-start;
opacity: 0.5; filter: grayscale(1);
}
.achievement-card.earned { opacity: 1; filter: none; }
.achievement-icon { font-size: 1.75rem; flex-shrink: 0; }
.achievement-title { font-weight: 600; font-size: 0.9rem; }
.achievement-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.earned-tag { font-size: 0.7rem; color: #7c3aed; font-weight: 600; }
.locked-tag { font-size: 0.7rem; color: #aaa; }
.cosmetic-list { .fc-character__lv-label {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem; font-size: 9px;
color: var(--fc-text-dim);
letter-spacing: .1em;
} }
.cosmetic-card {
display: flex; flex-direction: column; align-items: center; gap: 0.5rem; .fc-character__lv-num {
cursor: pointer; transition: border-color 0.2s; font-size: 32px;
color: var(--fc-gold);
text-shadow: 0 0 20px rgba(255, 216, 77, .8);
line-height: 1;
}
.fc-character__xp-hint {
font-size: 10px;
color: var(--fc-text-dim);
}
.fc-character__side {
min-width: 0;
}
.fc-character__quests-panel {
height: 100%;
}
/* ── QUESTS ──────────────────────────────────────────── */
.fc-character__quest-list {
display: flex;
flex-direction: column;
gap: var(--fc-space-3);
}
.fc-character__quest {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: var(--fc-space-3);
border-bottom: 1px dashed var(--fc-panel-edge);
}
.fc-character__quest:last-child {
border-bottom: none;
padding-bottom: 0;
}
.fc-character__quest--claimed { opacity: .5; }
.fc-character__quest-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.fc-character__quest-chip {
font-size: 7px;
padding: 3px 6px;
border-radius: 2px;
letter-spacing: .04em;
}
.fc-character__quest-chip--daily { background: var(--fc-accent); color: #fff; }
.fc-character__quest-chip--weekly { background: var(--fc-accent-2); color: var(--fc-bg); }
.fc-character__quest-chip--monthly { background: var(--fc-gold); color: var(--fc-bg); }
.fc-character__quest-xp {
font-size: 8px;
color: var(--fc-gold);
}
.fc-character__quest-title {
font-size: 12px;
color: var(--fc-text);
}
.fc-character__quest-progress {
display: flex;
align-items: center;
gap: var(--fc-space-2);
}
.fc-character__prog-label {
font-size: 10px;
color: var(--fc-text-dim);
white-space: nowrap;
}
.fc-character__btn-claim {
font-size: 8px;
padding: 6px 12px;
background: var(--fc-accent);
border: none;
color: #fff;
cursor: pointer;
border-radius: 2px;
letter-spacing: .05em;
align-self: flex-start;
transition: box-shadow .12s;
}
.fc-character__btn-claim:hover {
box-shadow: 0 0 16px rgba(255, 45, 149, .6);
}
.fc-character__claimed {
font-size: 10px;
color: var(--fc-green);
}
/* ── CONQUISTAS ──────────────────────────────────────── */
.fc-character__ach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--fc-space-3);
}
.fc-character__ach {
display: flex;
gap: var(--fc-space-2);
align-items: flex-start;
filter: grayscale(1);
opacity: .45;
transition: filter .2s, opacity .2s;
}
.fc-character__ach--earned {
filter: none;
opacity: 1;
}
.fc-character__ach-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
margin-top: 2px;
}
.fc-character__ach-title {
font-size: 8px;
color: var(--fc-text);
margin-bottom: 4px;
}
.fc-character__ach-desc {
font-size: 10px;
color: var(--fc-text-dim);
line-height: 1.4;
}
.fc-character__ach-tag {
font-size: 7px;
color: var(--fc-gold);
margin-top: 4px;
display: block;
}
.fc-character__locked {
font-size: 10px;
color: var(--fc-text-dim);
margin-top: 4px;
display: block;
}
/* ── COSMÉTICOS ─────────────────────────────────────── */
.fc-character__cos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: var(--fc-space-3);
}
.fc-character__cos {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
padding: var(--fc-space-2);
border: 2px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
transition: border-color .15s, box-shadow .15s;
}
.fc-character__cos:hover:not(.fc-character__cos--locked):not(.fc-character__cos--equipped) {
border-color: var(--fc-accent-3);
box-shadow: 0 0 12px rgba(168, 85, 247, .4);
}
.fc-character__cos--equipped {
border-color: var(--fc-accent-3);
box-shadow: 0 0 16px rgba(168, 85, 247, .5);
}
.fc-character__cos--locked {
opacity: .45;
cursor: default;
filter: grayscale(.7);
}
.fc-character__cos-img-wrap {
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fc-character__cos-img {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
}
.fc-character__cos-placeholder {
font-size: 28px;
color: var(--fc-text-dim);
}
.fc-character__cos-name {
font-size: 7px;
color: var(--fc-text);
text-align: center;
line-height: 1.3;
}
.fc-character__cos-lv {
font-size: 9px;
color: var(--fc-text-dim);
}
.fc-character__cos-equipped {
font-size: 7px;
color: var(--fc-accent-3);
}
.fc-character__cos-lock {
font-size: 11px;
}
.fc-empty {
font-size: 10px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-3) 0;
}
/* ── MOBILE ─────────────────────────────────────────── */
@media (max-width: 700px) {
.fc-character__hero {
grid-template-columns: 1fr;
}
.fc-character__sprite-wrap {
padding: var(--fc-space-4) 0;
}
} }
.cosmetic-card:hover:not(.locked):not(.equipped) { border-color: #7c3aed; }
.cosmetic-card.equipped { border-color: #7c3aed; background: #ede9fe; }
.cosmetic-card.locked { opacity: 0.5; cursor: default; }
.cosmetic-swatch { width: 48px; height: 48px; border-radius: 8px; border: 2px solid #e5e7eb; }
.cosmetic-name { font-size: 0.8rem; font-weight: 500; text-align: center; }
.cosmetic-unlock { font-size: 0.7rem; color: #888; }
.equipped-tag { font-size: 0.65rem; color: #7c3aed; font-weight: 700; }
</style> </style>
+249 -212
View File
@@ -2,260 +2,297 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game' import { useGameStore } from '@/stores/game'
import NeonPanel from '@/components/NeonPanel.vue'
import XPBar from '@/components/XPBar.vue' import XPBar from '@/components/XPBar.vue'
import CharacterSprite from '@/components/CharacterSprite.vue'
import MonthSwitcher from '@/components/MonthSwitcher.vue'
const store = useDashboardStore() const dash = useDashboardStore()
const gameStore = useGameStore() const game = useGameStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7)) const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => { onMounted(() => dash.fetch(currentMonth.value))
store.fetch(currentMonth.value)
gameStore.fetchSummary()
})
function changeMonth(delta: number) { function prev() {
const [y, m] = currentMonth.value.split('-').map(Number) const [y, m] = currentMonth.value.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1) const d = new Date(y, m - 2, 1)
currentMonth.value = d.toISOString().slice(0, 7) currentMonth.value = d.toISOString().slice(0, 7)
store.fetch(currentMonth.value) dash.fetch(currentMonth.value)
}
function next() {
const [y, m] = currentMonth.value.split('-').map(Number)
const d = new Date(y, m, 1)
currentMonth.value = d.toISOString().slice(0, 7)
dash.fetch(currentMonth.value)
} }
function fmt(v: number) { function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
} }
function fmtMonth(m: string) { const stageLabel = computed(() => {
const [y, mo] = m.split('-').map(Number) const [y, m] = currentMonth.value.split('-').map(Number)
return new Date(y, mo - 1, 1).toLocaleString('pt-BR', { month: 'short', year: 'numeric' }) const name = new Date(y, m - 1, 1).toLocaleString('pt-BR', { month: 'long' }).toUpperCase()
} return `:: STAGE ${m.toString().padStart(2,'0')} :: ${name} · ${y}`
const maxCategoryTotal = computed(() => {
if (!store.data?.by_category.length) return 1
return Math.max(...store.data.by_category.map((c) => c.total))
}) })
const maxEvolution = computed(() => { const maxCat = computed(() => {
if (!store.data?.monthly_evolution.length) return 1 if (!dash.data?.by_category.length) return 1
return Math.max(...store.data.monthly_evolution.flatMap((m) => [m.income, m.expenses])) return Math.max(...dash.data.by_category.map(c => c.total))
}) })
const maxEvo = computed(() => {
if (!dash.data?.monthly_evolution.length) return 1
return Math.max(...dash.data.monthly_evolution.flatMap(m => [m.income, m.expenses]))
})
const xpPct = computed(() => {
const p = game.summary?.profile
if (!p) return 0
return Math.round((p.xp / p.xp_to_next) * 100)
})
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
const savingsOk = computed(() => savingsPct.value >= 40)
</script> </script>
<template> <template>
<div class="page"> <div class="dash-page">
<!-- Month navigator --> <!-- Sub-header -->
<div class="top-bar"> <div class="dash-subheader">
<h1>Dashboard</h1> <span class="fc-pixel stage-label">{{ stageLabel }}</span>
<div class="month-nav"> <MonthSwitcher :month="currentMonth" @prev="prev" @next="next" />
<button class="btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ fmtMonth(currentMonth) }}</span>
<button class="btn" @click="changeMonth(1)"></button>
</div>
</div> </div>
<div v-if="store.loading" class="loading">Carregando</div> <div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div>
<template v-else-if="store.data"> <template v-else-if="dash.data">
<!-- Pending recurring alert --> <!-- Alert banner -->
<div v-if="store.data.pending_recurring > 0" class="alert-banner"> <div v-if="dash.data.pending_recurring > 0" class="dash-alert">
{{ store.data.pending_recurring }} recorrência{{ store.data.pending_recurring > 1 ? 's' : '' }} <span class="fc-blink" style="color:var(--fc-red)"></span>
sem cobertura neste mês <span class="fc-pixel" style="font-size:8px">
{{ dash.data.pending_recurring }} RECORRÊNCIA{{ dash.data.pending_recurring > 1 ? 'S' : '' }} AUSENTE{{ dash.data.pending_recurring > 1 ? 'S' : '' }}
</span>
</div> </div>
<!-- Summary row --> <div class="dash-grid">
<div class="summary-grid"> <!-- Left column -->
<div class="card"> <div class="dash-col-main">
<div class="card-label">Receitas</div>
<div class="card-value income">{{ fmt(store.data.total_income) }}</div>
</div>
<div class="card">
<div class="card-label">Gastos</div>
<div class="card-value expense">{{ fmt(store.data.total_expenses) }}</div>
</div>
<div class="card">
<div class="card-label">Saldo</div>
<div class="card-value" :class="store.data.total_income - store.data.total_expenses >= 0 ? 'income' : 'expense'">
{{ fmt(store.data.total_income - store.data.total_expenses) }}
</div>
</div>
<div class="card savings-card" :class="store.data.savings_pct >= 40 ? 'savings-ok' : 'savings-low'">
<div class="card-label">% Poupado</div>
<div class="savings-pct">{{ store.data.savings_pct.toFixed(1) }}%</div>
<div class="savings-target">Meta: 40%</div>
</div>
<div class="card patrimony-card" v-if="store.data.total_patrimony > 0 || store.data.total_patrimony < 0">
<div class="card-label">Patrimônio</div>
<div class="card-value" :class="store.data.total_patrimony >= 0 ? 'income' : 'expense'">
{{ fmt(store.data.total_patrimony) }}
</div>
<div class="savings-target">todas as contas</div>
</div>
<RouterLink to="/personagem" class="card character-mini-card" v-if="gameStore.summary">
<div class="card-label">Personagem</div>
<XPBar
:level="gameStore.summary.profile.level"
:xp="gameStore.summary.profile.xp"
:xp-to-next="gameStore.summary.profile.xp_to_next"
/>
</RouterLink>
</div>
<!-- Bottom section: two columns on wide, stacked on mobile --> <!-- Hero: savings -->
<div class="bottom-grid"> <NeonPanel :glow="savingsOk" :corners="true"
<!-- Category breakdown --> :variant="!savingsOk ? 'danger' : undefined" title="% POUPADO · MÊS">
<div class="card full-card"> <div class="hero-pct" :class="savingsOk ? 'fc-text-green fc-glow-green' : 'fc-text-red fc-glow-red'">
<h2>Gastos por categoria</h2> {{ savingsPct.toFixed(1) }}%
<div v-if="store.data.by_category.length === 0" class="empty">Nenhum gasto registrado</div>
<div v-else class="cat-list">
<div v-for="cat in store.data.by_category" :key="cat.category_name" class="cat-row">
<div class="cat-name">{{ cat.category_name }}</div>
<div class="cat-bar-wrap">
<div
class="cat-bar"
:style="{ width: (cat.total / maxCategoryTotal * 100).toFixed(1) + '%', background: cat.color }"
></div>
</div>
<div class="cat-amount">{{ fmt(cat.total) }}</div>
</div> </div>
</div> <XPBar :pct="savingsPct" size="lg" :success="savingsOk" :danger="!savingsOk" />
</div> <div class="hero-sub fc-mono">
META: 40% &nbsp;·&nbsp;
<!-- Monthly evolution --> <span :class="savingsOk ? 'fc-text-green' : 'fc-text-red'">
<div class="card full-card"> {{ savingsOk ? 'META BATIDA ✓' : 'ABAIXO DA META' }}
<h2>Evolução mensal</h2> </span>
<div v-if="store.data.monthly_evolution.length === 0" class="empty">Sem dados</div>
<div v-else class="evo-chart">
<div v-for="me in store.data.monthly_evolution" :key="me.month" class="evo-col">
<div class="evo-bars">
<div
class="evo-bar income-bar"
:style="{ height: maxEvolution > 0 ? (me.income / maxEvolution * 100).toFixed(1) + '%' : '0%' }"
:title="'Receita: ' + fmt(me.income)"
></div>
<div
class="evo-bar expense-bar"
:style="{ height: maxEvolution > 0 ? (me.expenses / maxEvolution * 100).toFixed(1) + '%' : '0%' }"
:title="'Gasto: ' + fmt(me.expenses)"
></div>
</div>
<div class="evo-label">{{ me.month.slice(5) }}/{{ me.month.slice(2, 4) }}</div>
</div> </div>
</div>
<div class="evo-legend">
<span class="legend-dot income-dot"></span> Receita
<span class="legend-dot expense-dot"></span> Gasto
</div>
</div>
</div>
<!-- Recent transactions --> <div class="hero-stats">
<div class="card full-card mt"> <div class="hero-stat">
<h2>Transações recentes</h2> <div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">RECEITAS</div>
<div v-if="store.data.recent_transactions.length === 0" class="empty">Nenhuma transação</div> <div class="fc-mono hero-val fc-text-green">{{ fmt(dash.data.total_income) }}</div>
<div v-else class="recent-list"> </div>
<div <div class="hero-stat">
v-for="t in store.data.recent_transactions" <div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">GASTOS</div>
:key="t.id" <div class="fc-mono hero-val" style="color:var(--fc-text)">{{ fmt(dash.data.total_expenses) }}</div>
class="recent-row" </div>
> <div v-if="dash.data.total_patrimony !== 0" class="hero-stat">
<span class="recent-date">{{ t.date }}</span> <div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">PATRIMÔNIO</div>
<span class="recent-desc">{{ t.description }}</span> <div class="fc-mono hero-val" :class="dash.data.total_patrimony >= 0 ? 'fc-text-green' : 'fc-text-red'">
<span class="recent-cat">{{ t.category_name }}</span> {{ fmt(dash.data.total_patrimony) }}
<span class="recent-amt" :class="t.type === 'income' ? 'income' : 'expense'"> </div>
{{ t.type === 'income' ? '+' : '-' }}{{ fmt(t.amount) }} </div>
</span> </div>
</div> </NeonPanel>
<!-- Category breakdown -->
<NeonPanel title="GASTOS · CATEGORIA">
<div v-if="!dash.data.by_category.length" class="empty fc-pixel">SEM DADOS</div>
<div v-else class="cat-list">
<div v-for="c in dash.data.by_category" :key="c.category_name" class="cat-row">
<div class="cat-name fc-pixel">{{ c.category_name }}</div>
<div class="cat-bar-wrap">
<div class="cat-bar-fill" :style="{ width: (c.total/maxCat*100).toFixed(1)+'%', background: c.color || 'var(--fc-accent-3)' }" />
</div>
<div class="cat-val fc-mono">{{ fmt(c.total) }}</div>
</div>
</div>
</NeonPanel>
<!-- Monthly evolution -->
<NeonPanel title="EVOLUÇÃO MENSAL">
<div v-if="!dash.data.monthly_evolution.length" class="empty fc-pixel">SEM DADOS</div>
<div v-else class="evo-wrap">
<div class="evo-chart">
<div v-for="me in dash.data.monthly_evolution" :key="me.month" class="evo-col">
<div class="evo-bars">
<div class="evo-bar income-bar"
:style="{ height: maxEvo > 0 ? (me.income/maxEvo*100).toFixed(1)+'%' : '0%' }"
:title="'Receita: '+fmt(me.income)" />
<div class="evo-bar expense-bar"
:style="{ height: maxEvo > 0 ? (me.expenses/maxEvo*100).toFixed(1)+'%' : '0%' }"
:title="'Gasto: '+fmt(me.expenses)" />
</div>
<div class="evo-label fc-mono">{{ me.month.slice(5) }}/{{ me.month.slice(2,4) }}</div>
</div>
</div>
<div class="evo-legend fc-pixel">
<span class="legend-dot" style="background:var(--fc-green)" /> RECEITA
<span class="legend-dot" style="background:var(--fc-accent)" /> GASTO
</div>
</div>
</NeonPanel>
<!-- Recent transactions -->
<NeonPanel title="TRANSAÇÕES RECENTES">
<div v-if="!dash.data.recent_transactions.length" class="empty fc-pixel">SEM TRANSAÇÕES</div>
<div v-else class="tx-list">
<div v-for="t in dash.data.recent_transactions" :key="t.id" class="tx-row">
<span class="tx-date fc-mono">{{ t.date }}</span>
<span class="tx-desc fc-mono">{{ t.description }}</span>
<span class="tx-cat fc-pixel">{{ t.category_name }}</span>
<span class="tx-val fc-mono" :class="t.type === 'income' ? 'fc-text-green' : ''">
{{ t.type === 'income' ? '+' : '' }}{{ fmt(t.amount) }}
</span>
</div>
</div>
</NeonPanel>
</div>
<!-- Right column -->
<div class="dash-col-side">
<!-- Character widget -->
<NeonPanel title="PERSONAGEM" :glow="true">
<RouterLink to="/personagem" class="char-widget-link">
<div class="char-widget-sprite">
<CharacterSprite :scale="3" :theme="game.equippedTheme" :bob="true" />
</div>
<div v-if="game.summary" class="char-widget-info">
<div class="fc-pixel" style="font-size:8px;color:var(--fc-gold)">
NV {{ game.summary.profile.level }}
</div>
<XPBar :pct="xpPct" size="md" />
<div class="fc-mono" style="font-size:10px;color:var(--fc-text-dim);margin-top:4px">
{{ game.summary.profile.xp }} / {{ game.summary.profile.xp_to_next }} XP
</div>
</div>
</RouterLink>
</NeonPanel>
<!-- Active quests -->
<NeonPanel title="QUESTS ATIVAS">
<div v-if="!game.summary?.active_quests.length" class="empty fc-pixel">SEM QUESTS</div>
<div v-else class="quest-list">
<div v-for="q in game.summary!.active_quests.slice(0,4)" :key="q.quest_id" class="quest-item">
<div class="quest-top">
<span class="fc-chip" :class="`quest-chip--${q.quest_type}`">
{{ q.quest_type === 'daily' ? 'DIA' : q.quest_type === 'weekly' ? 'SEM' : 'MÊS' }}
</span>
<span class="fc-pixel" style="font-size:7px;color:var(--fc-gold)">+{{ q.xp_reward }} XP</span>
</div>
<div class="quest-title fc-pixel">{{ q.title }}</div>
<XPBar v-if="q.target_count" :pct="Math.min(100,(q.current_count/q.target_count)*100)" size="sm" />
<XPBar v-else-if="q.target_pct" :pct="Math.min(100,(q.current_pct/q.target_pct)*100)" size="sm" />
</div>
</div>
</NeonPanel>
</div> </div>
</div> </div>
</template> </template>
<div v-else-if="store.error" class="error">Erro ao carregar: {{ store.error }}</div> <div v-else-if="dash.error" class="dash-error fc-pixel">ERRO: {{ dash.error }}</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; } .dash-page { max-width: 1100px; margin: 0 auto; padding: var(--fc-space-5); }
.dash-subheader {
.top-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem; } display: flex; align-items: center; justify-content: space-between;
h1 { font-size: 1.5rem; margin: 0; } margin-bottom: var(--fc-space-4); flex-wrap: wrap; gap: var(--fc-space-2);
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; color: #374151; }
.month-nav { display: flex; align-items: center; gap: 0.5rem; }
.month-label { font-size: 0.9rem; font-weight: 600; min-width: 7rem; text-align: center; }
.btn { padding: 0.35rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; font-size: 1rem; line-height: 1; }
.loading { text-align: center; padding: 3rem; color: #9ca3af; }
.error { color: #dc2626; padding: 2rem; text-align: center; }
.alert-banner {
background: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px;
padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.875rem; color: #92400e;
} }
.stage-label { font-size: 8px; color: var(--fc-accent-2); }
.dash-loading { font-size: 9px; color: var(--fc-text-dim); padding: 3rem; text-align: center; }
.dash-error { font-size: 9px; color: var(--fc-red); padding: 2rem; text-align: center; }
.dash-alert {
display: flex; align-items: center; gap: var(--fc-space-2);
background: rgba(255,59,107,.08); border: 1px solid var(--fc-red);
border-radius: var(--fc-radius); padding: 8px 12px; margin-bottom: var(--fc-space-3);
}
.dash-grid {
display: grid; grid-template-columns: 1.6fr 1fr; gap: var(--fc-space-4); align-items: start;
}
@media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr; } }
.dash-col-main, .dash-col-side { display: flex; flex-direction: column; gap: var(--fc-space-4); }
/* Summary */ /* Hero */
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; } .hero-pct { font-family: var(--fc-font-pixel); font-size: 48px; line-height: 1; margin-bottom: 12px; }
@media (max-width: 600px) { .summary-grid { grid-template-columns: 1fr 1fr; } } .hero-sub { font-size: 10px; color: var(--fc-text-dim); margin-top: 6px; }
.hero-stats { display: flex; gap: var(--fc-space-4); margin-top: 16px; flex-wrap: wrap; }
.hero-stat { display: flex; flex-direction: column; gap: 4px; }
.hero-val { font-size: 14px; font-weight: 700; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1rem; } /* Categories */
.card-label { font-size: 0.75rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.35rem; } .cat-list { display: flex; flex-direction: column; gap: 8px; }
.card-value { font-size: 1.15rem; font-weight: 700; } .cat-row { display: grid; grid-template-columns: 120px 1fr 90px; align-items: center; gap: 8px; }
.income { color: #059669; } .cat-name { font-size: 7px; color: var(--fc-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.expense { color: #dc2626; } .cat-bar-wrap { background: var(--fc-bg); border: 1px solid var(--fc-panel-edge); height: 8px; border-radius: 2px; overflow: hidden; }
.cat-bar-fill { height: 100%; border-radius: 2px; transition: width .4s; }
.cat-val { font-size: 11px; text-align: right; color: var(--fc-text); }
.savings-card { border-width: 2px; } /* Evolution */
.savings-ok { border-color: #34d399; background: #f0fdf4; } .evo-wrap { display: flex; flex-direction: column; gap: 8px; }
.savings-low { border-color: #f87171; background: #fef2f2; } .evo-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
.savings-pct { font-size: 2rem; font-weight: 800; line-height: 1; } .evo-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; }
.savings-ok .savings-pct { color: #059669; }
.savings-low .savings-pct { color: #dc2626; }
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
.character-mini-card { display: flex; flex-direction: column; gap: 0.5rem; text-decoration: none; }
/* Bottom grid */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
@media (max-width: 640px) { .bottom-grid { grid-template-columns: 1fr; } }
.full-card { padding: 1rem; }
.mt { margin-top: 0; }
.empty { color: #9ca3af; font-size: 0.875rem; padding: 1rem 0; }
/* Category bars */
.cat-list { display: flex; flex-direction: column; gap: 0.5rem; }
.cat-row { display: grid; grid-template-columns: 130px 1fr 90px; align-items: center; gap: 0.5rem; }
@media (max-width: 480px) { .cat-row { grid-template-columns: 1fr; gap: 0.2rem; } }
.cat-name { font-size: 0.8rem; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cat-bar-wrap { background: #f3f4f6; border-radius: 4px; height: 12px; overflow: hidden; }
.cat-bar { height: 100%; border-radius: 4px; min-width: 4px; transition: width 0.3s; }
.cat-amount { font-size: 0.8rem; font-weight: 600; color: #374151; text-align: right; }
/* Evolution chart */
.evo-chart { display: flex; align-items: flex-end; gap: 0.5rem; height: 120px; padding-bottom: 1.5rem; position: relative; }
.evo-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; }
.evo-bars { display: flex; align-items: flex-end; gap: 2px; height: 100%; width: 100%; } .evo-bars { display: flex; align-items: flex-end; gap: 2px; height: 100%; width: 100%; }
.evo-bar { flex: 1; border-radius: 3px 3px 0 0; min-height: 2px; transition: height 0.3s; } .evo-bar { flex: 1; min-height: 2px; border-radius: 2px 2px 0 0; transition: height .3s; }
.income-bar { background: #34d399; } .income-bar { background: var(--fc-green); }
.expense-bar { background: #f87171; } .expense-bar { background: var(--fc-accent); }
.evo-label { font-size: 0.65rem; color: #9ca3af; margin-top: 4px; white-space: nowrap; } .evo-label { font-size: 9px; color: var(--fc-text-dim); margin-top: 4px; }
.evo-legend { display: flex; align-items: center; gap: 1rem; font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem; } .evo-legend { display: flex; align-items: center; gap: 12px; font-size: 7px; color: var(--fc-text-dim); }
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 3px; } .legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 1px; margin-right: 3px; }
.income-dot { background: #34d399; }
.expense-dot { background: #f87171; }
/* Recent transactions */ /* Transactions */
.recent-list { display: flex; flex-direction: column; gap: 0; } .tx-list { display: flex; flex-direction: column; }
.recent-row { .tx-row {
display: grid; display: grid; grid-template-columns: 82px 1fr 90px 100px;
grid-template-columns: 90px 1fr 120px 120px; align-items: center; gap: 8px;
align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--fc-panel-edge);
padding: 0.45rem 0; font-size: 11px;
border-bottom: 1px solid #f3f4f6;
font-size: 0.85rem;
} }
@media (max-width: 500px) { .tx-row:last-child { border-bottom: none; }
.recent-row { grid-template-columns: 80px 1fr 90px; } .tx-date { font-size: 10px; color: var(--fc-text-dim); }
.recent-cat { display: none; } .tx-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tx-cat { font-size: 7px; color: var(--fc-text-dim); }
.tx-val { text-align: right; font-weight: 700; }
/* Character widget */
.char-widget-link { display: flex; gap: 12px; align-items: center; text-decoration: none; }
.char-widget-sprite { flex-shrink: 0; }
.char-widget-info { flex: 1; display: flex; flex-direction: column; gap: 6px; }
/* Quests */
.quest-list { display: flex; flex-direction: column; gap: 10px; }
.quest-item { display: flex; flex-direction: column; gap: 5px; }
.quest-top { display: flex; justify-content: space-between; align-items: center; }
.quest-title { font-size: 7px; color: var(--fc-text); }
.fc-chip {
font-family: var(--fc-font-pixel); font-size: 7px; padding: 3px 5px;
border-radius: 2px; letter-spacing: .05em; text-transform: uppercase;
}
.quest-chip--daily { background: var(--fc-accent); color: var(--fc-bg); }
.quest-chip--weekly { background: var(--fc-accent-2); color: var(--fc-bg); }
.quest-chip--monthly { background: var(--fc-gold); color: var(--fc-bg); }
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; }
@media (max-width: 767px) {
.dash-page { padding: 14px 12px 80px; }
.tx-row { grid-template-columns: 70px 1fr 80px; }
.tx-cat { display: none; }
.cat-row { grid-template-columns: 90px 1fr 80px; }
} }
.recent-date { color: #9ca3af; font-size: 0.8rem; }
.recent-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-cat { color: #6b7280; font-size: 0.8rem; }
.recent-amt { text-align: right; font-weight: 600; }
</style> </style>
+228 -134
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useImportStore, type CSVMapping } from '@/stores/import' import { useImportStore, type CSVMapping } from '@/stores/import'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useImportStore() const store = useImportStore()
@@ -27,10 +28,17 @@ function onFileChange(e: Event) {
showCSVConfig.value = file.name.toLowerCase().endsWith('.csv') 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() { async function doPreview() {
if (!selectedFile.value) return if (!selectedFile.value) return
const mapping = showCSVConfig.value ? csvMapping.value : undefined await store.preview(selectedFile.value, showCSVConfig.value ? csvMapping.value : undefined)
await store.preview(selectedFile.value, mapping)
} }
const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length) const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length)
@@ -42,122 +50,154 @@ function fmt(amount: number) {
</script> </script>
<template> <template>
<div class="page"> <div class="fc-view">
<h1>Importar Extrato</h1> <span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
<!-- Result banner --> <!-- Result banner -->
<div v-if="store.result" class="result-banner"> <NeonPanel v-if="store.result" variant="success" title="CONCLUÍDO">
<strong>Import concluído!</strong> <div class="fc-import-result">
{{ store.result.imported }} transações importadas · <span class="fc-mono fc-import-result__stat">
{{ store.result.duplicates }} duplicatas ignoradas · <span class="fc-text-green">{{ store.result.imported }}</span> importadas
{{ store.result.errors }} erros </span>
<button class="btn btn-ghost" style="margin-left:1rem" @click="store.reset(); selectedFile = null"> <span class="fc-mono fc-import-result__stat">
Importar outro <span class="fc-text-gold">{{ store.result.duplicates }}</span> duplicatas
</button> </span>
</div> <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> <template v-else>
<!-- File upload --> <!-- Upload area -->
<div class="upload-area" @click="fileInput?.click()" @dragover.prevent @drop.prevent="onFileChange"> <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" /> <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> <template v-if="!selectedFile">
<span v-else>📄 {{ selectedFile.name }}</span> <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> </div>
<!-- CSV config --> <!-- CSV config -->
<div v-if="showCSVConfig" class="csv-config"> <NeonPanel v-if="showCSVConfig" title="CONFIG CSV">
<h3>Configuração do CSV</h3> <div class="fc-import-cfg">
<div class="config-grid"> <label class="fc-label">
<label>Separador de campo Separador de campo
<select v-model="csvMapping.field_separator"> <select v-model="csvMapping.field_separator" class="fc-select">
<option value=";">Ponto e vírgula (;)</option> <option value=";">Ponto e vírgula (;)</option>
<option value=",">, Vírgula (,)</option> <option value=",">, Vírgula (,)</option>
</select> </select>
</label> </label>
<label>Separador decimal <label class="fc-label">
<select v-model="csvMapping.decimal_separator"> Separador decimal
<select v-model="csvMapping.decimal_separator" class="fc-select">
<option value=",">, Vírgula (1.234,56)</option> <option value=",">, Vírgula (1.234,56)</option>
<option value=".">. Ponto (1,234.56)</option> <option value=".">. Ponto (1,234.56)</option>
</select> </select>
</label> </label>
<label>Formato de data <label class="fc-label">
<input v-model="csvMapping.date_format" placeholder="02/01/2006" /> Formato de data
<input v-model="csvMapping.date_format" placeholder="02/01/2006" class="fc-input" />
</label> </label>
<label>Coluna da data (0-based) <label class="fc-label">
<input type="number" v-model.number="csvMapping.date_column" min="0" /> Coluna da data (0-based)
<input type="number" v-model.number="csvMapping.date_column" min="0" class="fc-input" />
</label> </label>
<label>Coluna do valor <label class="fc-label">
<input type="number" v-model.number="csvMapping.amount_column" min="0" /> Coluna do valor
<input type="number" v-model.number="csvMapping.amount_column" min="0" class="fc-input" />
</label> </label>
<label>Coluna da descrição <label class="fc-label">
<input type="number" v-model.number="csvMapping.description_column" min="0" /> Coluna da descrição
<input type="number" v-model.number="csvMapping.description_column" min="0" class="fc-input" />
</label> </label>
<label class="checkbox-label"> <label class="fc-label fc-import-cfg__checkbox">
<input type="checkbox" v-model="csvMapping.has_header" /> Arquivo tem cabeçalho <input type="checkbox" v-model="csvMapping.has_header" />
Arquivo tem cabeçalho
</label> </label>
</div> </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> </div>
<button <p v-if="store.error" class="fc-import-err fc-mono">{{ store.error }}</p>
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 -->
<!-- Preview table -->
<template v-if="store.rows.length > 0"> <template v-if="store.rows.length > 0">
<div class="preview-summary"> <div class="fc-import-summary">
<span class="badge-new">{{ newCount }} novas</span> <span class="fc-pixel fc-import-badge fc-import-badge--new">{{ newCount }} NOVAS</span>
<span class="badge-dup">{{ dupCount }} duplicatas</span> <span class="fc-pixel fc-import-badge fc-import-badge--dup">{{ dupCount }} DUPL.</span>
<span v-if="store.parseErrors.length" class="badge-err">{{ store.parseErrors.length }} erros de parse</span> <span v-if="store.parseErrors.length" class="fc-pixel fc-import-badge fc-import-badge--err">
{{ store.parseErrors.length }} ERROS
</span>
</div> </div>
<div v-if="store.parseErrors.length" class="error-list"> <div v-if="store.parseErrors.length" class="fc-import-perrors">
<p v-for="e in store.parseErrors" :key="e" class="form-error">{{ e }}</p> <p v-for="e in store.parseErrors" :key="e" class="fc-mono fc-import-err">{{ e }}</p>
</div> </div>
<div class="table-wrap"> <NeonPanel title="PRÉVIA">
<table class="preview-table"> <div class="fc-import-table-wrap">
<thead> <table class="fc-import-table">
<tr> <thead>
<th>Data</th> <tr>
<th>Descrição</th> <th class="fc-pixel">DATA</th>
<th>Valor</th> <th class="fc-pixel">DESCRIÇÃO</th>
<th>Tipo</th> <th class="fc-pixel">VALOR</th>
<th>Status</th> <th class="fc-pixel">TIPO</th>
</tr> <th class="fc-pixel">STATUS</th>
</thead> </tr>
<tbody> </thead>
<tr <tbody>
v-for="(row, i) in store.rows" <tr v-for="(row, i) in store.rows" :key="i" :class="{ 'fc-import-table__row--dup': row.is_duplicate }">
:key="i" <td class="fc-mono">{{ row.date }}</td>
:class="{ 'row-dup': row.is_duplicate }" <td class="fc-body">{{ row.description }}</td>
> <td class="fc-mono" :class="row.type === 'income' ? 'fc-text-green' : ''">
<td>{{ row.date }}</td> {{ fmt(row.amount) }}
<td>{{ row.description }}</td> </td>
<td :class="row.type === 'income' ? 'amt-income' : 'amt-expense'"> <td class="fc-mono">{{ row.type === 'income' ? 'Receita' : 'Gasto' }}</td>
{{ fmt(row.amount) }} <td>
</td> <span v-if="row.is_duplicate" class="fc-pixel fc-import-status fc-import-status--dup">DUP</span>
<td>{{ row.type === 'income' ? 'Receita' : 'Gasto' }}</td> <span v-else class="fc-pixel fc-import-status fc-import-status--new">NOVO</span>
<td> </td>
<span v-if="row.is_duplicate" class="badge badge-dup-sm">duplicata</span> </tr>
<span v-else class="badge badge-new-sm">novo</span> </tbody>
</td> </table>
</tr> </div>
</tbody> </NeonPanel>
</table>
</div>
<div class="confirm-bar"> <div class="fc-import-confirm">
<button class="btn btn-primary" :disabled="store.loading || newCount === 0" @click="store.confirm()"> <button
{{ store.loading ? 'Salvando' : `Confirmar ${newCount} transações` }} class="fc-btn fc-btn--primary"
:disabled="store.loading || newCount === 0"
@click="store.confirm()"
>
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
</button> </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> </div>
</template> </template>
</template> </template>
@@ -165,60 +205,114 @@ function fmt(amount: number) {
</template> </template>
<style scoped> <style scoped>
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; } .fc-view {
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; } max-width: 860px;
h3 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; } margin: 0 auto;
padding: var(--fc-space-4);
display: flex;
flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.upload-area { .fc-view__title {
border: 2px dashed #d1d5db; font-size: 11px;
border-radius: 8px; color: var(--fc-accent-2);
padding: 2.5rem; }
.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; text-align: center;
cursor: pointer; cursor: pointer;
color: #6b7280; display: flex;
margin-bottom: 1rem; flex-direction: column;
transition: border-color 0.15s; 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; } .fc-import-drop__icon { font-size: 18px; }
.config-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; } .fc-import-drop__hint { font-size: 13px; color: var(--fc-text-dim); }
.config-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #374151; } .fc-import-drop__fname { font-size: 12px; color: var(--fc-text); }
.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; } /* CSV config grid */
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; } .fc-import-cfg {
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } display: grid;
.btn-ghost { background: transparent; } grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0; } gap: var(--fc-space-3);
.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; } .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> </style>
+137 -58
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRecurringStore } from '@/stores/recurring' import { useRecurringStore } from '@/stores/recurring'
import { useCategoriesStore } from '@/stores/categories' import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useRecurringStore() const store = useRecurringStore()
const catStore = useCategoriesStore() const catStore = useCategoriesStore()
@@ -70,69 +71,147 @@ function fmt(v: number) {
</script> </script>
<template> <template>
<div class="page"> <div class="fc-view">
<h1>Configurações Recorrências</h1> <span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
<form class="form-card" @submit.prevent="submit"> <!-- Form -->
<h2>{{ editId !== null ? 'Editar recorrência' : 'Nova recorrência' }}</h2> <NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
<div class="form-row"> <form class="fc-rec-form" @submit.prevent="submit">
<input v-model="form.name" placeholder="Nome (ex: Netflix)" required class="input-name" /> <div class="fc-rec-form__row">
<input v-model="amountRaw" placeholder="55,90" required class="input-sm input-amount" /> <input v-model="form.name" placeholder="Nome (ex: Netflix)" required class="fc-input fc-rec-form__name" />
<input type="number" v-model.number="form.day_of_month" min="1" max="31" class="input-sm input-day" placeholder="Dia" /> <input v-model="amountRaw" placeholder="55,90" required class="fc-input fc-rec-form__amount" />
<select v-model="form.category_id" class="input-sm"> <div class="fc-label fc-rec-form__day-wrap">
<option :value="null">Sem categoria</option> Dia do mês
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option> <input
</select> type="number"
</div> v-model.number="form.day_of_month"
<p v-if="formError" class="form-error">{{ formError }}</p> min="1"
<div class="form-actions"> max="31"
<button type="submit" class="btn btn-primary">{{ editId !== null ? 'Salvar' : 'Adicionar' }}</button> class="fc-input fc-rec-form__day"
<button v-if="editId !== null" type="button" class="btn btn-ghost" @click="cancelEdit">Cancelar</button> />
</div> </div>
</form> <select v-model="form.category_id" class="fc-select fc-rec-form__cat">
<option :value="null">Sem categoria</option>
<p v-if="store.loading">Carregando</p> <option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<ul v-else class="recurring-list">
<li v-for="item in store.items" :key="item.id" class="recurring-item" :class="{ editing: editId === item.id }">
<div class="item-info">
<strong>{{ item.name }}</strong>
<span class="item-meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div> </div>
<div class="item-actions"> <p v-if="formError" class="fc-rec-form__error fc-mono">{{ formError }}</p>
<button class="btn btn-sm" @click="startEdit(item.id)">Editar</button> <div class="fc-rec-form__actions">
<button class="btn btn-sm btn-danger" @click="remove(item.id, item.name)">Excluir</button> <button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
</div> </div>
</li> </form>
<li v-if="store.items.length === 0" class="empty">Nenhuma recorrência cadastrada</li> </NeonPanel>
</ul>
<!-- List -->
<NeonPanel title="RECORRÊNCIAS">
<p v-if="store.loading" class="fc-rec-loading fc-mono">carregando...</p>
<ul v-else class="fc-rec-list">
<li
v-for="item in store.items"
:key="item.id"
class="fc-rec-item"
:class="{ 'fc-rec-item--editing': editId === item.id }"
>
<div class="fc-rec-item__info">
<span class="fc-body fc-rec-item__name">{{ item.name }}</span>
<span class="fc-mono fc-rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="fc-rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</li>
<li v-if="store.items.length === 0" class="fc-rec-empty fc-mono"> nenhuma recorrência cadastrada </li>
</ul>
</NeonPanel>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { max-width: 640px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; } .fc-view {
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; } max-width: 640px;
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; } margin: 0 auto;
.form-card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } padding: var(--fc-space-4);
.form-row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } display: flex;
.input-name { flex: 1; min-width: 150px; padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } flex-direction: column;
.input-sm { padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } gap: var(--fc-space-4);
.input-amount { width: 90px; } padding-bottom: 96px;
.input-day { width: 60px; } }
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0 0; }
.form-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; } .fc-view__title {
.btn { padding: 0.4rem 1rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 0.875rem; background: #fff; } font-size: 11px;
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; } color: var(--fc-accent-2);
.btn-ghost { background: transparent; } }
.btn-sm { padding: 0.25rem 0.6rem; }
.btn-danger { color: #dc2626; border-color: #fca5a5; } .fc-rec-form__row {
.recurring-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; } display: flex;
.recurring-item { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; flex-wrap: wrap; gap: 0.5rem; } gap: var(--fc-space-2);
.recurring-item.editing { border-color: #4f46e5; box-shadow: 0 0 0 2px #e0e7ff; } flex-wrap: wrap;
.item-info { display: flex; flex-direction: column; gap: 0.2rem; } align-items: flex-end;
.item-meta { font-size: 0.8rem; color: #6b7280; } }
.item-actions { display: flex; gap: 0.4rem; }
.empty { color: #9ca3af; text-align: center; padding: 2rem; } .fc-rec-form__name { flex: 1; min-width: 150px; }
.fc-rec-form__amount { width: 110px; }
.fc-rec-form__day-wrap { width: 90px; flex-shrink: 0; }
.fc-rec-form__day { width: 100%; }
.fc-rec-form__cat { width: 160px; }
.fc-rec-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-rec-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* List */
.fc-rec-loading, .fc-rec-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.fc-rec-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.fc-rec-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 12px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
flex-wrap: wrap;
transition: border-color .15s;
}
.fc-rec-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.fc-rec-item__info {
display: flex;
flex-direction: column;
gap: 4px;
}
.fc-rec-item__name { font-size: 14px; font-weight: 500; }
.fc-rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-rec-item__actions { display: flex; gap: var(--fc-space-1); }
</style> </style>
+196 -109
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useTransactionsStore, type TransactionInput } from '@/stores/transactions' import { useTransactionsStore, type TransactionInput } from '@/stores/transactions'
import { useCategoriesStore } from '@/stores/categories' import { useCategoriesStore } from '@/stores/categories'
import { useAccountsStore } from '@/stores/accounts' import { useAccountsStore } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue'
import MonthSwitcher from '@/components/MonthSwitcher.vue'
const store = useTransactionsStore() const store = useTransactionsStore()
const catStore = useCategoriesStore() const catStore = useCategoriesStore()
@@ -76,11 +78,7 @@ async function submit() {
async function remove(id: number) { async function remove(id: number) {
if (!confirm('Excluir esta transação?')) return if (!confirm('Excluir esta transação?')) return
try { try { await store.remove(id) } catch (e: any) { alert(e.message) }
await store.remove(id)
} catch (e: any) {
alert(e.message)
}
} }
function fmt(v: number) { function fmt(v: number) {
@@ -91,120 +89,209 @@ function catName(id: number | null) {
if (!id) return '—' if (!id) return '—'
return catStore.categories.find((c) => c.id === id)?.name ?? '—' return catStore.categories.find((c) => c.id === id)?.name ?? '—'
} }
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number)
return new Date(y, m - 1, 1).toLocaleString('pt-BR', { month: 'long', year: 'numeric' })
}
</script> </script>
<template> <template>
<div class="page"> <div class="fc-view">
<div class="page-header"> <!-- Header -->
<h1>Transações</h1> <div class="fc-view__header">
<div class="month-nav"> <span class="fc-pixel fc-view__title">:: TRANSAÇÕES</span>
<button class="btn" @click="changeMonth(-1)"></button> <MonthSwitcher
<span class="month-label">{{ currentMonth }}</span> :month="currentMonth"
<button class="btn" @click="changeMonth(1)"></button> @prev="changeMonth(-1)"
</div> @next="changeMonth(1)"
/>
</div> </div>
<form class="form-card" @submit.prevent="submit"> <!-- Form -->
<h2>{{ editId !== null ? 'Editar transação' : 'Nova transação' }}</h2> <NeonPanel :title="editId !== null ? 'EDITAR TRANSAÇÃO' : 'NOVA TRANSAÇÃO'">
<div class="form-row"> <form class="fc-tx-form" @submit.prevent="submit">
<input type="date" v-model="form.date" required class="input-sm" /> <div class="fc-tx-form__row">
<select v-model="form.type" class="input-sm"> <input type="date" v-model="form.date" required class="fc-input fc-tx-form__date" />
<option value="expense">Gasto</option> <select v-model="form.type" class="fc-select fc-tx-form__type">
<option value="income">Receita</option> <option value="expense">Gasto</option>
</select> <option value="income">Receita</option>
<input </select>
v-model="amountRaw" <input
placeholder="150,90" v-model="amountRaw"
required placeholder="150,90"
class="input-sm input-amount" required
/> class="fc-input fc-tx-form__amount"
<select v-model="form.category_id" class="input-sm"> />
<option :value="null">Sem categoria</option> <select v-model="form.category_id" class="fc-select fc-tx-form__cat">
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option> <option :value="null">Sem categoria</option>
</select> <option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
<select v-model="form.account_id" class="input-sm"> </select>
<option :value="null">Sem conta</option> <select v-model="form.account_id" class="fc-select fc-tx-form__acc">
<option v-for="a in accStore.accounts" :key="a.id" :value="a.id">{{ a.name }}</option> <option :value="null">Sem conta</option>
</select> <option v-for="a in accStore.accounts" :key="a.id" :value="a.id">{{ a.name }}</option>
<input v-model="form.description" placeholder="Descrição" required class="input-desc" /> </select>
</div> <input v-model="form.description" placeholder="Descrição" required class="fc-input fc-tx-form__desc" />
<p v-if="formError" class="form-error">{{ formError }}</p> </div>
<div class="form-actions"> <p v-if="formError" class="fc-tx-form__error fc-mono">{{ formError }}</p>
<button type="submit" class="btn btn-primary">{{ editId !== null ? 'Salvar' : 'Adicionar' }}</button> <div class="fc-tx-form__actions">
<button v-if="editId !== null" type="button" class="btn btn-ghost" @click="cancelEdit">Cancelar</button> <button type="submit" class="fc-btn fc-btn--primary">
</div> {{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}
</form> </button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">
CANCELAR
</button>
</div>
</form>
</NeonPanel>
<p v-if="store.loading">Carregando</p> <!-- Table -->
<NeonPanel title="HISTÓRICO">
<div v-else class="table-wrap"> <p v-if="store.loading" class="fc-tx-loading fc-mono">carregando...</p>
<table class="tx-table"> <template v-else>
<thead> <div class="fc-tx-table-wrap">
<tr> <table class="fc-tx-table">
<th>Data</th> <thead>
<th>Descrição</th> <tr>
<th>Categoria</th> <th class="fc-pixel">DATA</th>
<th>Valor</th> <th class="fc-pixel">DESCRIÇÃO</th>
<th>Origem</th> <th class="fc-pixel">CATEGORIA</th>
<th></th> <th class="fc-pixel">VALOR</th>
</tr> <th class="fc-pixel">ORIGEM</th>
</thead> <th></th>
<tbody> </tr>
<tr v-for="t in store.transactions" :key="t.id" :class="{ 'row-editing': editId === t.id }"> </thead>
<td>{{ t.date }}</td> <tbody>
<td>{{ t.description }}</td> <tr
<td>{{ catName(t.category_id) }}</td> v-for="t in store.transactions"
<td :class="t.type === 'income' ? 'amt-income' : 'amt-expense'">{{ fmt(t.amount) }}</td> :key="t.id"
<td> :class="{ 'fc-tx-table__row--editing': editId === t.id }"
<span class="badge" :class="t.source === 'manual' ? 'badge-manual' : 'badge-import'"> >
{{ t.source === 'manual' ? 'manual' : 'extrato' }} <td class="fc-mono fc-tx-table__date">{{ t.date }}</td>
</span> <td class="fc-body fc-tx-table__desc">{{ t.description }}</td>
</td> <td class="fc-mono fc-tx-table__cat">{{ catName(t.category_id) }}</td>
<td class="actions"> <td class="fc-mono fc-tx-table__amt" :class="t.type === 'income' ? 'fc-text-green' : ''">
<button v-if="t.source === 'manual'" class="btn btn-sm" @click="startEdit(t.id)">Editar</button> {{ fmt(t.amount) }}
<button v-if="t.source === 'manual'" class="btn btn-sm btn-danger" @click="remove(t.id)">Excluir</button> </td>
</td> <td>
</tr> <span class="fc-tx-chip fc-pixel" :class="t.source === 'manual' ? 'fc-tx-chip--manual' : 'fc-tx-chip--import'">
<tr v-if="store.transactions.length === 0"> {{ t.source === 'manual' ? 'MANUAL' : 'EXTRATO' }}
<td colspan="6" style="text-align:center;color:#9ca3af;padding:2rem">Nenhuma transação</td> </span>
</tr> </td>
</tbody> <td class="fc-tx-table__actions">
</table> <button v-if="t.source === 'manual'" class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(t.id)">EDITAR</button>
</div> <button v-if="t.source === 'manual'" class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(t.id)"></button>
</td>
</tr>
<tr v-if="store.transactions.length === 0">
<td colspan="6" class="fc-tx-table__empty fc-mono"> nenhuma transação </td>
</tr>
</tbody>
</table>
</div>
</template>
</NeonPanel>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; } .fc-view {
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem; } max-width: 1000px;
h1 { font-size: 1.5rem; margin: 0; } margin: 0 auto;
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; } padding: var(--fc-space-4);
.month-nav { display: flex; align-items: center; gap: 0.5rem; } display: flex;
.month-label { font-size: 0.9rem; font-weight: 600; min-width: 6rem; text-align: center; } flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.form-card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } .fc-view__header {
.form-row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } display: flex;
.input-sm { padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } align-items: center;
.input-amount { width: 100px; } justify-content: space-between;
.input-desc { flex: 1; min-width: 160px; padding: 0.45rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; } flex-wrap: wrap;
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0 0; } gap: var(--fc-space-3);
.form-actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; } }
.btn { padding: 0.4rem 1rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 0.875rem; background: #fff; } .fc-view__title {
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; } font-size: 11px;
.btn-ghost { background: transparent; } color: var(--fc-accent-2);
.btn-sm { padding: 0.25rem 0.6rem; } }
.btn-danger { color: #dc2626; border-color: #fca5a5; }
.table-wrap { overflow-x: auto; } /* Form */
.tx-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } .fc-tx-form__row {
.tx-table th { text-align: left; padding: 0.5rem 0.75rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb; white-space: nowrap; } display: flex;
.tx-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid #f3f4f6; } gap: var(--fc-space-2);
.row-editing { background: #eef2ff; } flex-wrap: wrap;
.amt-income { color: #059669; font-weight: 500; } align-items: flex-end;
.amt-expense { color: #dc2626; font-weight: 500; } }
.badge { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 4px; }
.badge-manual { background: #ede9fe; color: #5b21b6; } .fc-tx-form__date { width: 130px; }
.badge-import { background: #dbeafe; color: #1e40af; } .fc-tx-form__type { width: 100px; }
.actions { display: flex; gap: 0.4rem; white-space: nowrap; } .fc-tx-form__amount { width: 110px; }
.fc-tx-form__cat { width: 150px; }
.fc-tx-form__acc { width: 140px; }
.fc-tx-form__desc { flex: 1; min-width: 150px; }
.fc-tx-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-tx-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* Table */
.fc-tx-loading {
color: var(--fc-text-dim);
font-size: 11px;
text-align: center;
padding: var(--fc-space-4);
}
.fc-tx-table-wrap { overflow-x: auto; }
.fc-tx-table {
width: 100%;
border-collapse: collapse;
}
.fc-tx-table th {
font-size: 7px;
color: var(--fc-text-dim);
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--fc-panel-edge);
white-space: nowrap;
}
.fc-tx-table td {
font-size: 12px;
padding: 8px 10px;
border-bottom: 1px dashed var(--fc-panel-edge);
vertical-align: middle;
}
.fc-tx-table__row--editing td {
background: rgba(168,85,247,.08);
}
.fc-tx-table__date { color: var(--fc-text-dim); font-size: 11px; white-space: nowrap; }
.fc-tx-table__desc { color: var(--fc-text); }
.fc-tx-table__cat { color: var(--fc-text-dim); font-size: 11px; }
.fc-tx-table__amt { white-space: nowrap; font-weight: 500; }
.fc-tx-table__actions { display: flex; gap: 4px; white-space: nowrap; }
.fc-tx-table__empty { text-align: center; color: var(--fc-text-dim); font-size: 11px; padding: var(--fc-space-5) !important; }
.fc-tx-chip {
font-size: 6px;
padding: 3px 6px;
border-radius: 2px;
}
.fc-tx-chip--manual { background: rgba(168,85,247,.2); color: var(--fc-accent-3); }
.fc-tx-chip--import { background: rgba(0,240,255,.15); color: var(--fc-accent-2); }
</style> </style>