Files
sb-front/src/views/account/AccountDashboardView.vue
T
ByungCheol 1a85e448aa
Deploy / deploy (push) Failing after 11m25s
feat(shop): 포인트 상점 프론트엔드 구현
- accountApi: shopStatus(), buyShopItem(item) 추가; buyShopItem은 DEMO_WRITES에 등록
- router: /settings/shop → ShopView.vue 라우트 추가
- ShopView.vue: 포인트 잔액·상품 카드(계좌 슬롯/AI 통계) 구매 화면, PREMIUM 회원 안내 분기
- AccountInfoView: '기프트콘 교환' → '포인트 상점' 로 이름 변경 + 클릭 시 /settings/shop 이동
- AccountDashboardView: AI 코멘트 402 응답 시 크레딧 소진 안내 + 상점 이동 버튼 노출
- LAUNCH-PROGRESS: 포인트 상점  완료 표시, 다음 재개 지점 현행화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 00:33:13 +09:00

1211 lines
35 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 { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const unit = ref('MONTH') // DAY / WEEK / MONTH / YEAR
const tableYear = ref(now.getFullYear()) // 월별 총액 표 전용 연도
const currentYear = now.getFullYear()
const loading = ref(false)
const error = ref(null)
const budgetTotal = ref(0)
const spentTotal = ref(0)
const monthIncome = ref(0)
const monthExpense = ref(0)
const prevMonthExpense = ref(0) // 선택 월의 직전 달 지출 (대비용)
const barData = ref([]) // [{bucket, budget, expense}]
const monthlyStats = ref([]) // MONTH unit of year (income/expense)
const catType = ref('EXPENSE') // 분류별 파이차트 구분
const catData = ref([]) // [{category, total}]
const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
const trendData = ref([]) // [{month, netWorth}]
const aiBullets = ref([]) // AI 재무 코멘트(현황 관찰)
const aiTips = ref([]) // AI 절약 가이드(실행 제안)
const forecast = ref(null) // 월말 예상 지출(결정적 계산, 일회성 큰 지출 제외)
const forecastNote = ref('') // 예상 지출 근거 문장
const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
const forecastLoading = ref(false)
const aiLoading = ref(false)
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate()
}
function range(a, b) {
const r = []
for (let i = a; i <= b; i++) r.push(i)
return r
}
/* ===== 예산 대비 지출 도넛 ===== */
const R = 52
const C = 2 * Math.PI * R
const budgetRatio = computed(() => (budgetTotal.value > 0 ? Math.min(spentTotal.value / budgetTotal.value, 1) : 0))
const budgetPct = computed(() => (budgetTotal.value > 0 ? Math.round((spentTotal.value / budgetTotal.value) * 100) : 0))
const donutDash = computed(() => `${budgetRatio.value * C} ${C}`)
const donutColor = computed(() => {
const r = budgetTotal.value > 0 ? spentTotal.value / budgetTotal.value : 0
return r >= 1 ? '#c0392b' : r >= 0.8 ? '#e67e22' : '#2e7d32'
})
/* ===== 수입/지출 비율 ===== */
const ieTotal = computed(() => monthIncome.value + monthExpense.value)
const incomePct = computed(() => (ieTotal.value > 0 ? (monthIncome.value / ieTotal.value) * 100 : 0))
/* ===== 지난달 대비 이번달 지출 ===== */
const expenseDelta = computed(() => {
const prev = prevMonthExpense.value
const diff = monthExpense.value - prev
const pct = prev > 0 ? Math.round((diff / prev) * 1000) / 10 : null
return { prev, diff, pct }
})
/* ===== 월말 예상 지출 (결정적 계산 — 매 접속 동일값) =====
일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은
일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */
function robustDailyRate(days) {
if (!days.length) return { rate: 0, dropped: 0, droppedCount: 0 }
const sorted = [...days].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
// 비정기 큰 지출(대출상환·가전·여행 등)은 남은 날에 반복되지 않으므로,
// 중앙값의 3배(또는 10만원)를 넘는 '모든' 날을 하루평균 산정에서 제외(가장 큰 1건만이 아님).
const threshold = Math.max(median * 3, 100000)
const canTrim = sorted.length >= 4
const kept = []
let dropped = 0
let droppedCount = 0
for (const d of sorted) {
if (canTrim && d > threshold) {
dropped += d
droppedCount++
} else {
kept.push(d)
}
}
const rate = kept.length ? kept.reduce((a, b) => a + b, 0) / kept.length : 0
return { rate, dropped, droppedCount }
}
async function loadForecast() {
forecast.value = null
forecastNote.value = ''
forecastHint.value = ''
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
if (!isCurrent) return // 과거/미래 달은 추정 불필요
const totalDays = daysInMonth(year.value, month.value)
const elapsed = now.getDate()
if (elapsed < 1) return
// 초반엔 표본이 적어 추정이 크게 튀므로 일정 경과일 전에는 값 대신 안내만 표시
if (elapsed < FORECAST_MIN_DAYS) {
forecastHint.value = `${FORECAST_MIN_DAYS}일부터 표시돼요`
return
}
forecastLoading.value = true
try {
const daily = await accountApi.budgetPeriod({ unit: 'DAY', year: year.value, month: month.value })
const map = {}
;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0))
const days = []
for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0)
const { rate, dropped, droppedCount } = robustDailyRate(days)
const remaining = totalDays - elapsed
forecast.value = Math.round(monthExpense.value + rate * remaining)
forecastNote.value =
droppedCount > 0
? `대출상환 등 비정기 큰 지출 ${droppedCount}건(합계 ${won(dropped)})은 남은 ${remaining}일 추정에서 제외`
: `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정`
} catch {
forecast.value = null
} finally {
forecastLoading.value = false
}
}
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
const bars = computed(() => {
const map = {}
barData.value.forEach((d) => (map[d.bucket] = d))
let buckets
if (unit.value === 'DAY') buckets = range(1, daysInMonth(year.value, month.value)).map(String)
else if (unit.value === 'WEEK') buckets = range(1, Math.ceil(daysInMonth(year.value, month.value) / 7)).map(String)
else if (unit.value === 'MONTH') buckets = range(1, 12).map(String)
else buckets = barData.value.map((d) => d.bucket) // YEAR: 존재하는 연도
return buckets.map((b) => ({
label: unit.value === 'WEEK' ? `${b}` : b,
budget: map[b]?.budget || 0,
expense: map[b]?.expense || 0,
}))
})
const barMax = computed(() => Math.max(1, ...bars.value.flatMap((b) => [b.budget, b.expense])))
/* ===== 막대 차트 툴팁 (지출/예산 비율) ===== */
const chartRef = ref(null)
const tip = reactive({ show: false, x: 0, y: 0, label: '', budget: 0, expense: 0, ratio: 0 })
function showTip(b) {
tip.label = b.label
tip.budget = b.budget
tip.expense = b.expense
tip.ratio = b.budget > 0 ? Math.round((b.expense / b.budget) * 100) : 0
tip.show = true
}
function moveTip(e) {
const r = chartRef.value?.getBoundingClientRect()
if (!r) return
tip.x = e.clientX - r.left
tip.y = e.clientY - r.top
}
function hideTip() {
tip.show = false
}
/* ===== 분류별 파이차트 ===== */
const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60)
const PIE_C = 2 * Math.PI * PIE_R
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
// 분류별 집계를 대분류 기준으로 묶는다 (소분류까지 펼치면 항목이 너무 많아 스크롤이 길어짐).
// 소분류는 부모(대분류)명으로 합산, 매핑이 없으면 자기 자신을 대분류로 본다.
const catByMajor = computed(() => {
const list = categoryList.value.filter((c) => c.type === catType.value)
const byId = {}
list.forEach((c) => (byId[c.id] = c))
const toMajor = (name) => {
const c = list.find((x) => x.name === name)
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
return name
}
const map = {}
for (const d of catData.value) {
const major = toMajor(d.category)
map[major] = (map[major] || 0) + d.total
}
return Object.entries(map)
.map(([category, total]) => ({ category, total }))
.sort((a, b) => b.total - a.total)
})
const catTotal = computed(() => catByMajor.value.reduce((s, d) => s + d.total, 0))
const pieSegments = computed(() => {
const total = catTotal.value
if (total <= 0) return []
let acc = 0
return catByMajor.value.map((d, i) => {
const len = (d.total / total) * PIE_C
const seg = {
category: d.category,
total: d.total,
pct: Math.round((d.total / total) * 100),
color: PIE_COLORS[i % PIE_COLORS.length],
len,
offset: -acc,
}
acc += len
return seg
})
})
const catHover = ref(-1)
/* ===== 순자산 추이 ===== */
const trendView = computed(() => {
const data = trendData.value
if (!data.length) return { pts: [], line: '', area: '', zeroY: null, min: 0, max: 0 }
const vals = data.map((d) => d.netWorth)
const min = Math.min(...vals, 0)
const max = Math.max(...vals, 0)
const span = max - min || 1
const n = data.length
const weekly = data[0].month.length > 7 // 주간이면 'yyyy-MM-dd'
const pts = data.map((d, i) => {
let label, tip
if (weekly) {
const [, mm, dd] = d.month.split('-')
label = `${+mm}/${+dd}`
tip = `${+mm}/${+dd}`
} else {
label = d.month.slice(5) // MM
tip = d.month
}
return {
x: n === 1 ? 50 : (i / (n - 1)) * 100,
y: 100 - ((d.netWorth - min) / span) * 100,
month: tip,
netWorth: d.netWorth,
label,
// 점이 많으면(주간) 축 라벨을 격주로만 표기해 겹침 방지
showLabel: n <= 8 || i % 2 === 0 || i === n - 1,
}
})
const line = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const area = `${pts[0].x.toFixed(2)},100 ` + line + ` ${pts[n - 1].x.toFixed(2)},100`
const zeroY = min < 0 && max > 0 ? 100 - ((0 - min) / span) * 100 : null
return { pts, line, area, zeroY, min, max }
})
const trendTip = reactive({ show: false, x: 0, y: 0, month: '', netWorth: 0 })
function showTrendTip(p) {
trendTip.month = p.month
trendTip.netWorth = p.netWorth
trendTip.x = p.x
trendTip.y = p.y
trendTip.show = true
}
function hideTrendTip() {
trendTip.show = false
}
async function loadCat() {
try {
catData.value = await accountApi.categoryStats({ type: catType.value, year: year.value, month: month.value })
} catch {
catData.value = []
}
}
async function loadCategoryList() {
try {
categoryList.value = await accountApi.categories()
} catch {
categoryList.value = []
}
}
function setCatType(t) {
catType.value = t
loadCat()
}
async function loadTrend() {
try {
trendData.value = await accountApi.netWorthTrend({ unit: 'WEEK', weeks: 13 })
} catch {
trendData.value = []
}
}
/* ===== 월별 총액 목록 ===== */
const monthlyRows = computed(() => {
const map = {}
monthlyStats.value.forEach((d) => (map[d.bucket] = d))
return range(1, 12).map((m) => {
const d = map[String(m)] || { income: 0, expense: 0 }
return { month: m, income: d.income, expense: d.expense, net: d.income - d.expense }
})
})
async function loadBar() {
try {
barData.value = await accountApi.budgetPeriod({ unit: unit.value, year: year.value, month: month.value })
} catch {
barData.value = []
}
}
async function load() {
loading.value = true
error.value = null
try {
const pmY = month.value === 1 ? year.value - 1 : year.value
const pmM = month.value === 1 ? 12 : month.value - 1
const [status, summary, prevSummary] = await Promise.all([
accountApi.budgetStatus({ year: year.value, month: month.value }),
accountApi.summary({ year: year.value, month: month.value }),
accountApi.summary({ year: pmY, month: pmM }),
loadBar(),
loadCat(),
])
budgetTotal.value = status.reduce((s, x) => s + x.monthlyBudget, 0)
spentTotal.value = status.reduce((s, x) => s + x.spent, 0)
monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense
prevMonthExpense.value = prevSummary.totalExpense
loadForecast() // 결정적 예상 지출(비동기)
loadAiComment() // 집계가 준비된 뒤 AI 코멘트 생성(비동기, 화면 로딩 막지 않음)
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
async function loadAiComment() {
aiLoading.value = true
aiNoCredit.value = false
aiBullets.value = []
aiTips.value = []
try {
const res = await accountApi.aiComment({
year: year.value,
month: month.value,
income: monthIncome.value,
expense: monthExpense.value,
prevExpense: prevMonthExpense.value,
budget: budgetTotal.value,
spent: spentTotal.value,
categories: (catData.value || []).slice(0, 8).map((c) => ({ category: c.category, total: c.total })),
})
aiBullets.value = res?.bullets || []
aiTips.value = res?.tips || []
} catch (e) {
if (e.response?.status === 402) {
aiNoCredit.value = true
}
aiBullets.value = []
aiTips.value = []
} finally {
aiLoading.value = false
}
}
function setUnit(u) {
unit.value = u
loadBar()
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else month.value -= 1
load()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else month.value += 1
load()
}
// 월별 총액 표: 전용 연도
async function loadMonthly() {
try {
monthlyStats.value = await accountApi.stats({ unit: 'MONTH', year: tableYear.value })
} catch {
monthlyStats.value = []
}
}
function prevTableYear() {
tableYear.value -= 1
loadMonthly()
}
function nextTableYear() {
if (tableYear.value >= currentYear) return
tableYear.value += 1
loadMonthly()
}
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영 (중복 없이)
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 대시보드 조회를 막지 않는다
}
load()
loadMonthly()
loadTrend()
loadCategoryList()
})
</script>
<template>
<section class="dash">
<div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<template v-else>
<!-- 무료 회원 AI 크레딧 소진 안내 -->
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
<div class="ai-comment-head"> AI 코멘트</div>
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 있어요.</p>
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 </button>
</div>
<!-- AI 재무 코멘트 + 절약 가이드: 이번 집계 해석 -->
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
<div class="ai-comment-head"> 이번 AI 코멘트</div>
<div v-if="aiLoading" class="ai-loading">
<span class="ai-loading-text">집계를 분석하고 있어요</span>
<div class="ai-loading-bar"><span></span></div>
</div>
<template v-else>
<ul v-if="aiBullets.length" class="ai-comment-list">
<li v-for="(b, i) in aiBullets" :key="i">{{ b }}</li>
</ul>
<div v-if="aiTips.length" class="ai-tips">
<div class="ai-tips-head">💡 절약 가이드</div>
<ul class="ai-comment-list">
<li v-for="(t, i) in aiTips" :key="i">{{ t }}</li>
</ul>
</div>
</template>
</div>
<!-- 이번 현황 (예산 도넛 + 수입/지출 통합) -->
<div class="panel month-overview">
<h2>이번 현황</h2>
<div class="mo-grid">
<div class="mo-budget">
<div v-if="budgetTotal > 0" class="donut-wrap">
<svg viewBox="0 0 120 120" class="donut">
<circle cx="60" cy="60" :r="R" fill="none" stroke="var(--color-background-mute)" stroke-width="14" />
<circle
cx="60" cy="60" :r="R" fill="none" :stroke="donutColor" stroke-width="14"
:stroke-dasharray="donutDash" stroke-linecap="round" transform="rotate(-90 60 60)"
/>
<text x="60" y="56" text-anchor="middle" class="donut-pct">{{ budgetPct }}%</text>
<text x="60" y="74" text-anchor="middle" class="donut-sub">지출/예산</text>
</svg>
<div class="donut-legend">
<div>지출 <b>{{ won(spentTotal) }}</b></div>
<div>예산 <b>{{ won(budgetTotal) }}</b></div>
<div :class="budgetTotal - spentTotal < 0 ? 'neg' : ''">
{{ budgetTotal - spentTotal >= 0 ? '잔여' : '초과' }} <b>{{ won(Math.abs(budgetTotal - spentTotal)) }}</b>
</div>
</div>
</div>
<p v-else class="empty">설정된 예산이 없습니다.</p>
</div>
<div class="ie">
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
<div v-if="expenseDelta.prev || monthExpense" class="ie-row compare">
<span>지난달 대비 <small>지난달 {{ won(expenseDelta.prev) }}</small></span>
<b :class="expenseDelta.diff > 0 ? 'expense' : 'income'">
{{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
</b>
</div>
<div v-if="forecastHint" class="ie-row forecast">
<span>예상 지출 <small>{{ forecastHint }}</small></span>
<b class="pending"></b>
</div>
<div v-else-if="forecastLoading && !forecast" class="ie-row forecast">
<span>예상 지출 <small>계산 </small></span>
<b class="pending">···</b>
</div>
<div v-else-if="forecast" class="ie-row forecast">
<span>예상 지출 <small>월말 추정</small></span>
<b class="expense">{{ won(forecast) }}</b>
</div>
<div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
<p v-if="forecastNote" class="forecast-note">{{ forecastNote }}</p>
</div>
</div>
</div>
<!-- 분류별 파이차트 -->
<div class="panel">
<div class="panel-head">
<h2>분류별 {{ catType === 'EXPENSE' ? '지출' : '수입' }}</h2>
<div class="unit-tabs">
<button type="button" :class="{ active: catType === 'EXPENSE' }" @click="setCatType('EXPENSE')">지출</button>
<button type="button" :class="{ active: catType === 'INCOME' }" @click="setCatType('INCOME')">수입</button>
</div>
</div>
<div v-if="pieSegments.length" class="pie-wrap">
<svg viewBox="0 0 120 120" class="pie">
<circle cx="60" cy="60" :r="PIE_R" fill="none" stroke="var(--color-background-mute)" stroke-width="20" />
<circle
v-for="(s, i) in pieSegments" :key="i"
cx="60" cy="60" :r="PIE_R" fill="none" :stroke="s.color"
:stroke-width="catHover === i ? 24 : 20"
:stroke-dasharray="`${s.len} ${PIE_C - s.len}`" :stroke-dashoffset="s.offset"
transform="rotate(-90 60 60)" class="pie-seg"
@mouseenter="catHover = i" @mouseleave="catHover = -1"
/>
<text x="60" y="57" text-anchor="middle" class="pie-center">{{ won(catTotal) }}</text>
<text x="60" y="72" text-anchor="middle" class="pie-center-sub"> {{ catType === 'EXPENSE' ? '지출' : '수입' }}</text>
</svg>
<ul class="pie-legend">
<li v-for="(s, i) in pieSegments" :key="i" :class="{ hover: catHover === i }"
@mouseenter="catHover = i" @mouseleave="catHover = -1">
<span class="sw" :style="{ background: s.color }"></span>
<span class="cat">{{ s.category }}</span>
<span class="pct">{{ s.pct }}%</span>
<span class="amt">{{ won(s.total) }}</span>
</li>
</ul>
</div>
<p v-if="!pieSegments.length" class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
</div>
<!-- 기간별 예산 대비 지출 막대 차트 -->
<div class="panel">
<div class="panel-head">
<h2>기간별 예산 대비 지출</h2>
<div class="unit-tabs">
<button type="button" :class="{ active: unit === 'MONTH' }" @click="setUnit('MONTH')">월별</button>
<button type="button" :class="{ active: unit === 'YEAR' }" @click="setUnit('YEAR')">년별</button>
</div>
</div>
<div class="legend-inline">
<span class="dot budget"></span>예산 <span class="dot expense"></span>지출
</div>
<div v-if="bars.length" ref="chartRef" class="chart-wrap" @mousemove="moveTip" @mouseleave="hideTip">
<div class="bar-chart">
<div v-for="(b, i) in bars" :key="i" class="bar-col" @mouseenter="showTip(b)">
<div class="bars">
<div class="bar budget" :style="{ height: (b.budget / barMax) * 100 + '%' }"></div>
<div class="bar expense" :style="{ height: (b.expense / barMax) * 100 + '%' }"></div>
</div>
<span class="bar-label">{{ b.label }}</span>
</div>
</div>
<div v-if="tip.show" class="tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
<div class="t-label">{{ tip.label }}{{ unit === 'YEAR' ? '년' : unit === 'MONTH' ? '월' : '' }}</div>
<div><span class="dot budget"></span>예산 {{ won(tip.budget) }}</div>
<div><span class="dot expense"></span>지출 {{ won(tip.expense) }}</div>
<div class="t-ratio" :class="tip.ratio >= 100 ? 'over' : ''">예산 대비 {{ tip.ratio }}%</div>
</div>
</div>
<p v-else class="empty">데이터가 없습니다.</p>
</div>
<!-- 순자산 추이 (최근 3개월, 주간) -->
<div class="panel">
<h2>순자산 추이 <span class="sub-note">최근 3개월 · 주간</span></h2>
<div v-if="trendView.pts.length" class="trend-wrap" @mouseleave="hideTrendTip">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="trend-svg">
<line v-if="trendView.zeroY !== null" x1="0" :y1="trendView.zeroY" x2="100" :y2="trendView.zeroY"
class="zero-line" vector-effect="non-scaling-stroke" />
<polygon :points="trendView.area" class="trend-area" />
<polyline :points="trendView.line" class="trend-line" vector-effect="non-scaling-stroke" />
</svg>
<!-- /호버 마커 (HTML 오버레이) -->
<div
v-for="(p, i) in trendView.pts" :key="i" class="trend-dot"
:style="{ left: p.x + '%', top: p.y + '%' }"
@mouseenter="showTrendTip(p)"
></div>
<div class="trend-labels">
<span v-for="(p, i) in trendView.pts" :key="i" class="t-axis" :style="{ left: p.x + '%' }">{{ p.showLabel ? p.label : '' }}</span>
</div>
<div v-if="trendTip.show" class="tooltip trend-tip" :style="{ left: trendTip.x + '%', top: trendTip.y + '%' }">
<div class="t-label">{{ trendTip.month }}</div>
<div :class="trendTip.netWorth < 0 ? 'neg' : ''">순자산 {{ won(trendTip.netWorth) }}</div>
</div>
</div>
<p v-else class="empty">데이터가 없습니다.</p>
</div>
<!-- 월별 수입/지출 총액 목록 -->
<div class="panel">
<div class="panel-head">
<h2>월별 총액</h2>
<div class="year-nav">
<IconBtn icon="chevronLeft" title="이전 연도" size="sm" @click="prevTableYear" />
<span class="year-label">{{ tableYear }}</span>
<IconBtn icon="chevronRight" title="다음 연도" size="sm" :disabled="tableYear >= currentYear" @click="nextTableYear" />
</div>
</div>
<table class="monthly">
<thead>
<tr><th></th><th class="num">수입</th><th class="num">지출</th><th class="num">수지</th></tr>
</thead>
<tbody>
<tr v-for="r in monthlyRows" :key="r.month">
<td>{{ r.month }}</td>
<td class="num income">{{ won(r.income) }}</td>
<td class="num expense">{{ won(r.expense) }}</td>
<td class="num" :class="r.net < 0 ? 'expense' : 'income'">{{ won(r.net) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<style scoped>
.dash {
max-width: 760px;
margin: 0 auto;
}
.head {
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.month-nav button {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
.panel {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
/* AI 재무 코멘트 카드 */
.ai-comment {
border: 1px solid hsla(160, 100%, 37%, 0.45);
border-radius: 12px;
background: hsla(160, 100%, 37%, 0.07);
padding: 0.9rem 1.1rem;
margin-bottom: 1rem;
}
.ai-comment-head {
font-weight: 700;
font-size: 0.95rem;
color: hsla(160, 100%, 32%, 1);
margin-bottom: 0.5rem;
}
.ai-loading-text {
font-size: 0.85rem;
opacity: 0.65;
}
.ai-loading-bar {
height: 4px;
margin-top: 0.45rem;
border-radius: 999px;
background: hsla(160, 100%, 37%, 0.15);
overflow: hidden;
}
.ai-loading-bar > span {
display: block;
width: 40%;
height: 100%;
border-radius: 999px;
background: hsla(160, 100%, 37%, 0.85);
animation: ai-indeterminate 1.1s ease-in-out infinite;
}
@keyframes ai-indeterminate {
0% { transform: translateX(-110%); }
100% { transform: translateX(360%); }
}
.ie-row .pending {
opacity: 0.4;
letter-spacing: 2px;
}
.ai-comment-list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ai-comment-list li {
font-size: 0.9rem;
line-height: 1.45;
}
.ai-tips {
margin-top: 0.7rem;
padding-top: 0.7rem;
border-top: 1px dashed hsla(160, 100%, 37%, 0.35);
}
.ai-tips-head {
font-weight: 700;
font-size: 0.88rem;
color: #b8860b;
margin-bottom: 0.4rem;
}
.ai-no-credit {
border-color: hsla(220, 80%, 60%, 0.4);
}
.ai-credit-msg {
font-size: 0.88rem;
color: var(--color-muted, #888);
line-height: 1.5;
margin: 0.4rem 0 0.7rem;
}
.ai-shop-btn {
display: inline-block;
padding: 6px 14px;
border-radius: 7px;
border: none;
background: #2563eb;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.top-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.top-grid .panel {
margin-bottom: 1rem;
}
/* 이번 달 현황: 예산 도넛 + 수입/지출을 한 패널에 좌우로 */
.month-overview .mo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.5rem;
align-items: center;
}
.mo-budget {
min-width: 0;
}
@media (max-width: 700px) {
.month-overview .mo-grid {
grid-template-columns: 1fr;
gap: 0.9rem;
}
.mo-budget {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.9rem;
}
}
.donut-wrap {
display: flex;
align-items: center;
gap: 1rem;
}
.donut {
width: 120px;
height: 120px;
flex-shrink: 0;
overflow: visible;
}
.donut-pct {
font-size: 20px;
font-weight: 700;
fill: var(--color-heading);
}
.donut-sub {
font-size: 9px;
fill: var(--color-text);
opacity: 0.6;
}
.donut-legend {
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.donut-legend .neg b {
color: #c0392b;
}
.ie-row {
display: flex;
justify-content: space-between;
padding: 0.2rem 0;
font-size: 0.9rem;
}
.ie-row.forecast {
opacity: 0.85;
}
.ie-row.forecast small,
.ie-row.compare small {
font-size: 0.72rem;
opacity: 0.6;
font-weight: 400;
margin-left: 0.25rem;
}
.forecast-note {
margin: 0.3rem 0 0;
font-size: 0.72rem;
opacity: 0.6;
}
.ie-row.net {
border-top: 1px solid var(--color-border);
margin-top: 0.3rem;
padding-top: 0.4rem;
}
.income {
color: #2e7d32;
}
.expense {
color: #c0392b;
}
.ie-bar {
height: 8px;
background: #c0392b;
border-radius: 999px;
overflow: hidden;
margin: 0.4rem 0;
}
.ie-fill.income {
height: 100%;
background: #2e7d32;
}
.unit-tabs {
display: flex;
gap: 0.2rem;
}
.unit-tabs button {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.unit-tabs button.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.legend-inline {
font-size: 0.78rem;
opacity: 0.75;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.3rem;
}
.dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
margin-left: 0.5rem;
}
.dot.income {
background: #2e7d32;
}
.dot.expense {
background: #c0392b;
}
.dot.budget {
background: #3b82a6;
}
.chart-wrap {
position: relative;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 160px;
overflow-x: auto;
padding-top: 0.5rem;
}
.bar-col {
flex: 1;
min-width: 16px;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: 1px;
width: 100%;
justify-content: center;
}
.bar {
width: 45%;
min-height: 1px;
border-radius: 2px 2px 0 0;
}
.bar.income {
background: #2e7d32;
}
.bar.budget {
background: #3b82a6;
}
.bar.expense {
background: #c0392b;
}
.tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 12px));
pointer-events: none;
background: #1e1e1e;
color: #fff;
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
z-index: 5;
line-height: 1.5;
}
.tooltip .t-label {
font-weight: 700;
margin-bottom: 0.2rem;
}
.tooltip .dot {
margin-left: 0;
margin-right: 0.3rem;
}
.tooltip .t-ratio {
margin-top: 0.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 0.25rem;
font-weight: 700;
}
.tooltip .t-ratio.over {
color: #ff6b6b;
}
.bar-label {
font-size: 0.65rem;
opacity: 0.7;
margin-top: 0.2rem;
white-space: nowrap;
}
/* 분류별 파이차트 */
.pie-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.pie {
width: 100%;
max-width: 170px;
height: auto;
aspect-ratio: 1 / 1;
flex-shrink: 0;
overflow: visible;
}
.pie-seg {
cursor: pointer;
transition: stroke-width 0.12s ease;
}
.pie-center {
font-size: 13px;
font-weight: 700;
fill: var(--color-heading);
}
.pie-center-sub {
font-size: 8px;
fill: var(--color-text);
opacity: 0.6;
}
.major-rollup {
margin-top: 0.8rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--color-border);
}
.mr-head {
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.3rem;
}
.mr-list {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.1rem 1rem;
margin: 0;
padding: 0;
}
.mr-list li {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
padding: 0.1rem 0;
}
.mr-cat {
font-weight: 600;
}
.pie-legend {
list-style: none;
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.1rem 1rem;
font-size: 0.85rem;
}
.pie-legend li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.3rem;
border-radius: 4px;
}
.pie-legend li.hover {
background: var(--color-background-mute);
}
.pie-legend .sw {
width: 11px;
height: 11px;
border-radius: 2px;
flex-shrink: 0;
}
.pie-legend .cat {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pie-legend .pct {
opacity: 0.65;
min-width: 2.5rem;
text-align: right;
}
.pie-legend .amt {
font-weight: 600;
min-width: 5rem;
text-align: right;
}
/* 순자산 추이 */
.sub-note {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.6;
}
.trend-wrap {
position: relative;
height: 170px;
padding-bottom: 1.1rem;
}
.trend-svg {
width: 100%;
height: 150px;
overflow: visible;
}
.trend-area {
fill: hsla(160, 100%, 37%, 0.12);
}
.trend-line {
fill: none;
stroke: hsla(160, 100%, 37%, 1);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.zero-line {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.trend-dot {
position: absolute;
width: 9px;
height: 9px;
margin: -4.5px 0 0 -4.5px;
border-radius: 50%;
background: var(--color-background);
border: 2px solid hsla(160, 100%, 37%, 1);
cursor: pointer;
}
.trend-dot:hover {
transform: scale(1.3);
}
.trend-labels {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1rem;
}
.t-axis {
position: absolute;
transform: translateX(-50%);
font-size: 0.62rem;
opacity: 0.6;
white-space: nowrap;
}
.trend-tip {
position: absolute;
}
.trend-tip .neg {
color: #ff6b6b;
}
.year-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-nav button {
padding: 0.2rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.year-nav button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.year-label {
font-weight: 600;
min-width: 4rem;
text-align: center;
}
.monthly {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.monthly th,
.monthly td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.monthly .num {
text-align: right;
}
.empty {
font-size: 0.85rem;
opacity: 0.6;
padding: 1rem 0;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
@media (max-width: 768px) {
.dash {
max-width: 100%;
}
.top-grid {
grid-template-columns: 1fr;
}
.monthly th,
.monthly td {
padding: 0.4rem 0.3rem;
}
}
</style>