Files
sb-front/src/views/HomeView.vue
T
ByungCheol 095ba942eb
CI / build (push) Failing after 10m39s
feat: 홈 캘린더 일별 내역 팝업(모달)화 + 릴리스 노트 갱신
- HomeView: 날짜 탭 시 아래 인라인 패널 대신 중앙 팝업으로 내역 표시(시인성)
  · @mouseenter 제거로 터치 '두 번 탭' 버그 해소(단일 탭 동작)
- docs/release-2026-06-06.md: 이어진 세션 변경(하단 내비·설정/계정·매매수정·보안·테스트/배포·안드로이드) 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:09:05 +09:00

759 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi'
const auth = useAuthStore()
const ui = useUiStore()
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)
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 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 {
const [sum, nw, status, list] = await Promise.all([
accountApi.summary({ year, month }),
accountApi.netWorth(),
accountApi.budgetStatus({ year, month }),
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 || []
} 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">
<h1>안녕하세요, {{ displayName }} 👋</h1>
<p class="today">{{ monthLabel }} 가계부 요약</p>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<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="/account/budget" class="more">예산 </RouterLink>
</div>
<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>
</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">Slim Budget</h1>
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
<div class="cta">
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
<button v-if="ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
</div>
<p class="cta-note">{{ 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;
}
.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;
gap: 0.6rem;
justify-content: center;
margin-top: 1.4rem;
}
.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>