Files
sb-front/src/views/HomeView.vue
T
sb 1ee264f2ab
Deploy / deploy (push) Failing after 11m5s
chore(i18n): 전체 화면 '고정지출' → '정기결제' 문구 변경
- 사이드바·헤더·홈·업그레이드·데모·정기결제/내역 화면 라벨 통일
- 백업 엑셀 시트명(backup.js)은 기존 백업 복원 호환 위해 '고정지출' 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:20:24 +09:00

941 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi'
import { reminders } from '@/native/reminders'
import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo'
function startDemo() {
enterDemo()
router.push('/account/entries')
}
const auth = useAuthStore()
const ui = useUiStore()
const router = useRouter()
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const monthLabel = `${year}${month}`
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const budgetTotal = ref(0)
const spentTotal = ref(0)
const entries = ref([])
const loading = ref(false)
const error = ref(null)
const displayName = computed(() => auth.user?.name || auth.user?.loginId || '')
// 예산 대비 지출
const budgetPct = computed(() =>
budgetTotal.value > 0 ? Math.round((spentTotal.value / budgetTotal.value) * 100) : 0,
)
const budgetLeft = computed(() => budgetTotal.value - spentTotal.value)
const budgetOver = computed(() => budgetLeft.value < 0)
// ===== 신규 유저 시작하기 안내 =====
// 데이터가 전혀 없으면(이번 달 내역 0 + 자산/부채 0) 첫 사용 가이드를 노출.
// 한 번 닫으면 다시 보이지 않도록 로컬에 기록.
const isEmpty = computed(() =>
entries.value.length === 0 &&
networth.value.totalAssets === 0 &&
networth.value.totalLiabilities === 0,
)
const gsDismissed = ref(localStorage.getItem('home:gettingStarted:dismissed') === '1')
const showGettingStarted = computed(() => !loading.value && !error.value && isEmpty.value && !gsDismissed.value)
function dismissGettingStarted() {
gsDismissed.value = true
try { localStorage.setItem('home:gettingStarted:dismissed', '1') } catch { /* 무시 */ }
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
// ===== 일별 수입/지출 캘린더 =====
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
// 일자 → { income, expense, items[] }
const dayMap = computed(() => {
const m = {}
for (const e of entries.value) {
const d = (e.entryDate || '').slice(0, 10)
const day = Number(d.slice(8, 10))
if (!day) continue
if (!m[day]) m[day] = { income: 0, expense: 0, items: [] }
if (e.type === 'INCOME') m[day].income += e.amount || 0
else if (e.type === 'EXPENSE') m[day].expense += e.amount || 0
m[day].items.push(e)
}
return m
})
const daysInMonth = computed(() => new Date(year, month, 0).getDate())
const firstWeekday = computed(() => new Date(year, month - 1, 1).getDay()) // 0=일
const todayDate = now.getFullYear() === year && now.getMonth() + 1 === month ? now.getDate() : 0
// ===== 오늘 기록 넛지(습관 유도) =====
const todayCount = computed(() => dayMap.value[todayDate]?.items?.length || 0)
// 오늘부터 거슬러 연속으로 기록된 일수(이번 달 데이터 기준)
const streak = computed(() => {
if (!todayDate) return 0
let s = 0
for (let d = todayDate; d >= 1; d--) {
if ((dayMap.value[d]?.items?.length || 0) > 0) s++
else break
}
return s
})
const calendarCells = computed(() => {
const cells = []
for (let i = 0; i < firstWeekday.value; i++) cells.push(null)
for (let d = 1; d <= daysInMonth.value; d++) {
const info = dayMap.value[d] || { income: 0, expense: 0, items: [] }
cells.push({ day: d, ...info })
}
return cells
})
// 탭한 날짜의 내역을 팝업(모달)으로 표시 — 캘린더 아래가 가려져도 잘 보이게
const activeDay = ref(0)
const dayModalOpen = ref(false)
function openDay(c) {
activeDay.value = c.day
dayModalOpen.value = true
}
function closeDayModal() {
dayModalOpen.value = false
}
const activeItems = computed(() => (activeDay.value ? dayMap.value[activeDay.value]?.items || [] : []))
const activeDateLabel = computed(() => {
if (!activeDay.value) return ''
const wd = WEEKDAYS[new Date(year, month - 1, activeDay.value).getDay()]
return `${month}${activeDay.value}일 (${wd})`
})
function itemLabel(e) {
if (e.type === 'TRANSFER') return '이체'
return e.category || (e.type === 'INCOME' ? '수입' : '지출')
}
function itemSign(t) {
return t === 'INCOME' ? '+' : t === 'EXPENSE' ? '-' : ''
}
// 가계부 주요 화면 바로가기
const shortcuts = [
{ to: '/account/entries', icon: '📒', label: '가계부 내역', desc: '수입·지출 기록' },
{ to: '/account/stats', icon: '📊', label: '통계', desc: '차트로 보기' },
{ to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' },
{ to: '/account/budget', icon: '🎯', label: '예산 설정', desc: '예산 대비 지출' },
{ to: '/account/recurrings', icon: '🔁', label: '정기결제', desc: '매월·매년 반복' },
{ to: '/account/categories', icon: '🗂️', label: '분류 관리', desc: '카테고리' },
]
async function load() {
if (!auth.isAuthenticated) return
loading.value = true
error.value = null
try {
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
const [sum, nw, status, list] = await Promise.all([
accountApi.summary({ year, month }),
accountApi.netWorth(),
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
accountApi.list({ year, month }),
])
summary.value = sum
networth.value = nw
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
entries.value = list || []
// 오늘 기록 여부로 가계부 리마인더 보정(오늘 기록 있으면 오늘자 알림 제외)
if (reminders.isNative()) {
const t = new Date()
const today = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`
reminders.scheduleEntryReminder((list || []).some((e) => e.entryDate === today))
}
} catch (e) {
error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
// 로그인/로그아웃 전환 시 갱신
watch(() => auth.isAuthenticated, (v) => {
if (v) load()
else {
summary.value = { totalIncome: 0, totalExpense: 0, balance: 0 }
networth.value = { totalAssets: 0, totalLiabilities: 0, netWorth: 0 }
budgetTotal.value = 0
spentTotal.value = 0
entries.value = []
activeDay.value = 0
}
})
onMounted(load)
</script>
<template>
<section class="home">
<!-- ===== 로그인: 요약 대시보드 ===== -->
<template v-if="auth.isAuthenticated">
<header class="greet">
<template v-if="loading">
<h1>{{ monthLabel }} 가계부</h1>
</template>
<template v-else-if="todayCount > 0">
<h1 v-if="streak >= 2">{{ streak }} 연속 기록 중이에요 🔥</h1>
<h1 v-else>오늘 {{ todayCount }} 기록했어요 </h1>
<p class="today">{{ monthLabel }} 가계부 요약</p>
</template>
<template v-else>
<h1>{{ displayName }}, 오늘은 아직 기록이 없어요</h1>
<RouterLink to="/account/entries" class="greet-cta">+ 지금 기록하기</RouterLink>
</template>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<!-- 신규 유저 시작하기 안내 (데이터 없을 때만) -->
<section v-if="showGettingStarted" class="getting-started">
<button type="button" class="gs-close" aria-label="안내 닫기" @click="dismissGettingStarted">×</button>
<h2 class="gs-title">가계부를 시작해볼까요? 🐷</h2>
<p class="gs-sub">아래 순서로 기록을 시작해보세요.</p>
<div class="gs-steps">
<RouterLink to="/account/wallets" class="gs-step">
<span class="gs-num">1</span>
<span class="gs-text"><b>계좌 등록하기</b><span>은행·카드·현금 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/categories" class="gs-step">
<span class="gs-num">2</span>
<span class="gs-text"><b>기본 분류 불러오기</b><span>식비·교통 카테고리 준비</span></span>
<span class="gs-arrow"></span>
</RouterLink>
<RouterLink to="/account/entries" class="gs-step">
<span class="gs-num">3</span>
<span class="gs-text"><b> 내역 기록하기</b><span>수입·지출을 추가</span></span>
<span class="gs-arrow"></span>
</RouterLink>
</div>
</section>
<div class="cards">
<!-- 이번 수입/지출/수지 -->
<div class="card">
<div class="card-head">
<span class="card-title">이번 </span>
<RouterLink to="/account/entries" class="more">내역 </RouterLink>
</div>
<div class="stat-row">
<div class="stat">
<span class="label">수입</span>
<span class="value income">{{ won(summary.totalIncome) }}</span>
</div>
<div class="stat">
<span class="label">지출</span>
<span class="value expense">{{ won(summary.totalExpense) }}</span>
</div>
<div class="stat">
<span class="label">수지</span>
<span class="value" :class="summary.balance < 0 ? 'expense' : 'income'">{{ won(summary.balance) }}</span>
</div>
</div>
</div>
<!-- 순자산 -->
<div class="card">
<div class="card-head">
<span class="card-title">순자산</span>
<RouterLink to="/account/wallets" class="more">계좌 </RouterLink>
</div>
<div class="stat-row">
<div class="stat">
<span class="label">총자산</span>
<span class="value income">{{ won(networth.totalAssets) }}</span>
</div>
<div class="stat">
<span class="label">총부채</span>
<span class="value expense">{{ won(networth.totalLiabilities) }}</span>
</div>
<div class="stat">
<span class="label">순자산</span>
<span class="value">{{ won(networth.netWorth) }}</span>
</div>
</div>
</div>
</div>
<!-- 예산 대비 지출 (유료 전용 무료 회원은 업그레이드 안내) -->
<div class="card budget-card">
<div class="card-head">
<span class="card-title">{{ month }} 예산 대비 지출</span>
<RouterLink :to="auth.isPremium ? '/account/budget' : '/upgrade'" class="more">예산 </RouterLink>
</div>
<RouterLink v-if="!auth.isPremium" to="/upgrade" class="budget-empty">👑 예산 관리는 유료 멤버십 기능이에요 </RouterLink>
<template v-else>
<template v-if="budgetTotal > 0">
<div class="budget-bar">
<div
class="budget-fill" :class="{ over: budgetOver }"
:style="{ width: Math.min(budgetPct, 100) + '%' }"
></div>
</div>
<div class="budget-info">
<span class="b-pct" :class="{ over: budgetOver }">{{ budgetPct }}%</span>
<span class="b-detail">
지출 <b class="expense">{{ won(spentTotal) }}</b> / 예산 <b>{{ won(budgetTotal) }}</b>
· <span :class="budgetOver ? 'expense' : 'income'">{{ budgetOver ? '초과' : '잔여' }} {{ won(Math.abs(budgetLeft)) }}</span>
</span>
</div>
</template>
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink>
</template>
</div>
<!-- 일별 수입/지출 캘린더 -->
<div class="card cal-card">
<div class="card-head">
<span class="card-title">{{ month }} 일별 수입·지출</span>
<RouterLink to="/account/entries" class="more">내역 </RouterLink>
</div>
<div class="cal-grid cal-head">
<span v-for="(w, i) in WEEKDAYS" :key="w" class="cal-wd" :class="{ sun: i === 0, sat: i === 6 }">{{ w }}</span>
</div>
<div class="cal-grid">
<template v-for="(c, i) in calendarCells" :key="i">
<span v-if="!c" class="cal-cell empty"></span>
<button
v-else
type="button"
class="cal-cell"
:class="{ today: c.day === todayDate, active: c.day === activeDay, has: c.items.length }"
@click="openDay(c)"
>
<span class="cal-day">{{ c.day }}</span>
<span v-if="c.income" class="cal-amt income">+{{ won(c.income) }}</span>
<span v-if="c.expense" class="cal-amt expense">-{{ won(c.expense) }}</span>
</button>
</template>
</div>
<p class="cal-hint">날짜를 탭하면 날의 내역이 표시됩니다.</p>
</div>
<!-- 일별 내역 팝업 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="dayModalOpen" class="day-modal-backdrop" @click.self="closeDayModal">
<div class="day-modal" role="dialog" aria-modal="true" aria-label="일별 내역">
<button class="day-modal-close" type="button" aria-label="닫기" @click="closeDayModal">×</button>
<div class="dm-date">{{ activeDateLabel }}</div>
<div class="dm-sum">
<span v-if="dayMap[activeDay]?.income" class="income">수입 +{{ won(dayMap[activeDay].income) }}</span>
<span v-if="dayMap[activeDay]?.expense" class="expense">지출 -{{ won(dayMap[activeDay].expense) }}</span>
</div>
<ul v-if="activeItems.length" class="dm-list">
<li v-for="(e, idx) in activeItems" :key="idx" class="dm-item">
<span class="dm-cat">{{ itemLabel(e) }}</span>
<span v-if="e.memo" class="dm-memo">{{ e.memo }}</span>
<span class="dm-amt" :class="{ income: e.type === 'INCOME', expense: e.type === 'EXPENSE' }">
{{ itemSign(e.type) }}{{ won(e.amount) }}
</span>
</li>
</ul>
<p v-else class="dm-empty"> 날은 내역이 없습니다.</p>
</div>
</div>
</Transition>
</Teleport>
<!-- 바로가기 -->
<div class="shortcuts">
<RouterLink v-for="s in shortcuts" :key="s.to" :to="s.to" class="shortcut">
<span class="sc-icon">{{ s.icon }}</span>
<span class="sc-text">
<span class="sc-label">{{ s.label }}</span>
<span class="sc-desc">{{ s.desc }}</span>
</span>
</RouterLink>
</div>
</template>
<!-- ===== 비로그인: 랜딩 ===== -->
<template v-else>
<div class="landing">
<div class="hero-card">
<h1 class="brand">돈돼지 가계부</h1>
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
<div class="cta">
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
<button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
<button type="button" class="btn" @click="startDemo">로그인 없이 둘러보기</button>
</div>
<p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
</div>
<ul class="features">
<li><span class="f-icon">📒</span><b>간편한 가계부</b><span>수입·지출을 빠르게 기록하고 일별로 정리</span></li>
<li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자를 한곳에서 순자산까지</span></li>
<li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출, 분류별 차트로 한눈에</span></li>
</ul>
</div>
</template>
</section>
</template>
<style scoped>
.home {
max-width: 760px;
margin: 0 auto;
}
/* ===== 로그인 대시보드 ===== */
.greet h1 {
font-size: 1.4rem;
}
.greet .today {
margin-top: 0.2rem;
opacity: 0.65;
font-size: 0.9rem;
}
.greet-cta {
display: inline-flex;
align-items: center;
margin-top: 0.5rem;
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.88rem;
font-weight: 700;
}
/* 신규 유저 시작하기 안내 */
.getting-started {
position: relative;
margin: 1.2rem 0;
padding: 1.1rem 1.2rem 1.2rem;
border: 1px solid hsla(160, 100%, 37%, 0.35);
border-radius: 14px;
background: hsla(160, 100%, 37%, 0.06);
}
.gs-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.5;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.gs-close:hover {
opacity: 0.9;
}
.gs-title {
font-size: 1.1rem;
color: var(--color-heading);
}
.gs-sub {
margin-top: 0.25rem;
font-size: 0.86rem;
opacity: 0.7;
}
.gs-steps {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gs-step {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background);
color: var(--color-text);
text-decoration: none;
}
.gs-step:hover {
border-color: hsla(160, 100%, 37%, 0.6);
}
.gs-num {
flex: none;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.82rem;
font-weight: 700;
}
.gs-text {
display: flex;
flex-direction: column;
}
.gs-text b {
font-size: 0.92rem;
}
.gs-text span {
font-size: 0.78rem;
opacity: 0.65;
}
.gs-arrow {
margin-left: auto;
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin: 1.2rem 0;
}
.card {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1rem 1.1rem;
background: var(--color-background-soft);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.7rem;
}
.card-title {
font-weight: 700;
font-size: 0.95rem;
}
.more {
font-size: 0.78rem;
opacity: 0.7;
text-decoration: none;
color: var(--color-text);
}
.more:hover {
opacity: 1;
color: hsla(160, 100%, 37%, 1);
}
.stat-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.stat .label {
font-size: 0.76rem;
opacity: 0.65;
}
.stat .value {
font-size: 1rem;
font-weight: 700;
white-space: nowrap;
}
.value.income {
color: #2e7d32;
}
.value.expense {
color: #c0392b;
}
/* 예산 대비 지출 카드 */
.budget-card {
margin-bottom: 1.2rem;
}
.budget-bar {
height: 12px;
border-radius: 6px;
background: var(--color-background-mute);
overflow: hidden;
}
.budget-fill {
height: 100%;
border-radius: 6px;
background: hsla(160, 100%, 37%, 1);
transition: width 0.3s ease;
}
.budget-fill.over {
background: #c0392b;
}
.budget-info {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-top: 0.6rem;
flex-wrap: wrap;
}
.b-pct {
font-size: 1.1rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.b-pct.over {
color: #c0392b;
}
.b-detail {
font-size: 0.82rem;
opacity: 0.8;
}
.budget-empty {
display: block;
font-size: 0.85rem;
opacity: 0.7;
text-decoration: none;
color: var(--color-text);
}
.budget-empty:hover {
color: hsla(160, 100%, 37%, 1);
}
/* 일별 캘린더 */
.cal-card {
margin-bottom: 1.2rem;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 3px;
}
.cal-head {
margin-bottom: 4px;
}
.cal-wd {
text-align: center;
font-size: 0.72rem;
opacity: 0.6;
padding: 2px 0;
}
.cal-wd.sun {
color: #c0392b;
}
.cal-wd.sat {
color: #2c6bbf;
}
.cal-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
min-height: 46px;
padding: 3px 4px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
overflow: hidden;
}
.cal-cell.empty {
border: none;
background: transparent;
cursor: default;
min-height: 0;
}
.cal-cell.has {
border-color: hsla(160, 100%, 37%, 0.4);
}
.cal-cell.today {
box-shadow: inset 0 0 0 1.5px hsla(160, 100%, 37%, 0.8);
}
.cal-cell.active {
background: hsla(160, 100%, 37%, 0.12);
border-color: hsla(160, 100%, 37%, 0.7);
}
.cal-day {
font-size: 0.72rem;
font-weight: 600;
opacity: 0.75;
}
.cal-amt {
font-size: 0.62rem;
font-weight: 700;
line-height: 1.1;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-amt.income {
color: #2e7d32;
}
.cal-amt.expense {
color: #c0392b;
}
.cal-hint {
margin: 0.6rem 0 0;
font-size: 0.76rem;
opacity: 0.5;
text-align: center;
}
/* 일별 내역 팝업 */
.day-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.day-modal {
position: relative;
width: 100%;
max-width: 360px;
max-height: 75vh;
overflow-y: auto;
padding: 1.4rem 1.25rem 1.25rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
.day-modal-close {
position: absolute;
top: 0.4rem;
right: 0.55rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.dm-date {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.4rem;
}
.dm-sum {
display: flex;
gap: 0.8rem;
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.7rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--color-border);
}
.dm-sum .income {
color: #2e7d32;
}
.dm-sum .expense {
color: #c0392b;
}
.dm-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dm-item {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.86rem;
}
.dm-cat {
font-weight: 600;
flex-shrink: 0;
}
.dm-memo {
opacity: 0.6;
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-amt {
margin-left: auto;
font-weight: 700;
white-space: nowrap;
}
.dm-amt.income {
color: #2e7d32;
}
.dm-amt.expense {
color: #c0392b;
}
.dm-empty {
font-size: 0.86rem;
opacity: 0.6;
margin: 0.5rem 0 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 바로가기 그리드 */
.shortcuts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.7rem;
}
.shortcut {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
text-decoration: none;
color: var(--color-text);
background: var(--color-background);
transition: border-color 0.15s, transform 0.1s;
}
.shortcut:hover {
border-color: hsla(160, 100%, 37%, 0.6);
transform: translateY(-1px);
}
.sc-icon {
font-size: 1.4rem;
line-height: 1;
}
.sc-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.sc-label {
font-weight: 600;
font-size: 0.9rem;
}
.sc-desc {
font-size: 0.74rem;
opacity: 0.6;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
/* ===== 비로그인 랜딩 ===== */
.landing {
padding: 1rem 0 2rem;
}
.hero-card {
text-align: center;
padding: 2.6rem 1.5rem;
border-radius: 14px;
background:
radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.18), transparent 60%),
var(--color-background-soft);
border: 1px solid var(--color-border);
}
.brand {
font-size: 2rem;
letter-spacing: -0.5px;
}
.tagline {
margin-top: 0.5rem;
opacity: 0.75;
}
.cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-top: 1.4rem;
}
.cta .btn {
width: 100%;
max-width: 260px;
}
.btn {
padding: 0.6rem 1.4rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.95rem;
cursor: pointer;
}
.btn.primary {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 600;
}
.cta-note {
margin-top: 0.9rem;
font-size: 0.8rem;
opacity: 0.6;
}
.features {
list-style: none;
padding: 0;
margin: 1.6rem 0 0;
display: grid;
gap: 0.7rem;
}
.features li {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 0.8rem;
align-items: center;
padding: 0.9rem 1rem;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.features .f-icon {
grid-row: 1 / 3;
font-size: 1.6rem;
}
.features b {
font-size: 0.95rem;
}
.features li > span:last-child {
font-size: 0.82rem;
opacity: 0.65;
}
@media (max-width: 768px) {
.cards {
grid-template-columns: 1fr;
}
.shortcuts {
grid-template-columns: repeat(2, 1fr);
}
.brand {
font-size: 1.7rem;
}
}
</style>