- 통계: 순자산 추이 주간(최근 3개월), 이번달 예상 지출(run-rate), 지난달 대비 지출 - 가계부: 내역→고정지출 바로 등록, 날짜별 접기/펴기(기본 접힘·오늘만 펼침·모두 토글) - 계좌 관리 탭 한 줄(넘치면 가로 스크롤) - 릴리스 노트 갱신(23~32) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ 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') // 분류별 파이차트 구분
|
||||
@@ -52,6 +53,25 @@ const donutColor = computed(() => {
|
||||
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 }
|
||||
})
|
||||
|
||||
/* ===== 이번달 예상 지출 (실제 지출 추세로 월말 추정, run-rate) =====
|
||||
경과일까지 실제 지출을 일평균으로 환산해 그 달 총일수로 추정. 이번 달에만 표시. */
|
||||
const expenseForecast = computed(() => {
|
||||
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
|
||||
if (!isCurrent) return null // 지난/미래 달은 추정 불필요(이미 확정 또는 데이터 없음)
|
||||
const totalDays = daysInMonth(year.value, month.value)
|
||||
const elapsed = now.getDate()
|
||||
const projected = elapsed > 0 ? Math.round((monthExpense.value / elapsed) * totalDays) : monthExpense.value
|
||||
return { projected, elapsed, totalDays }
|
||||
})
|
||||
|
||||
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
|
||||
const bars = computed(() => {
|
||||
const map = {}
|
||||
@@ -123,13 +143,27 @@ const trendView = computed(() => {
|
||||
const max = Math.max(...vals, 0)
|
||||
const span = max - min || 1
|
||||
const n = data.length
|
||||
const pts = data.map((d, i) => ({
|
||||
x: n === 1 ? 50 : (i / (n - 1)) * 100,
|
||||
y: 100 - ((d.netWorth - min) / span) * 100,
|
||||
month: d.month,
|
||||
netWorth: d.netWorth,
|
||||
label: d.month.slice(5), // MM
|
||||
}))
|
||||
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
|
||||
@@ -160,7 +194,7 @@ function setCatType(t) {
|
||||
}
|
||||
async function loadTrend() {
|
||||
try {
|
||||
trendData.value = await accountApi.netWorthTrend({ months: 12 })
|
||||
trendData.value = await accountApi.netWorthTrend({ unit: 'WEEK', weeks: 13 })
|
||||
} catch {
|
||||
trendData.value = []
|
||||
}
|
||||
@@ -188,9 +222,12 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [status, summary] = await Promise.all([
|
||||
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(),
|
||||
])
|
||||
@@ -198,6 +235,7 @@ async function load() {
|
||||
spentTotal.value = status.reduce((s, x) => s + x.spent, 0)
|
||||
monthIncome.value = summary.totalIncome
|
||||
monthExpense.value = summary.totalExpense
|
||||
prevMonthExpense.value = prevSummary.totalExpense
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
@@ -301,10 +339,23 @@ onMounted(async () => {
|
||||
<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="expenseForecast" class="ie-row forecast">
|
||||
<span>예상 지출 <small>월말 추정</small></span>
|
||||
<b class="expense">{{ won(expenseForecast.projected) }}</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="expenseForecast" class="forecast-note">
|
||||
최근 추세 기준({{ expenseForecast.elapsed }}/{{ expenseForecast.totalDays }}일 · 현재 {{ won(monthExpense) }})<template v-if="budgetTotal > 0">, 예산 대비 {{ Math.round((expenseForecast.projected / budgetTotal) * 100) }}%</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,9 +428,9 @@ onMounted(async () => {
|
||||
<p v-else class="empty">데이터가 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 순자산 추이 (최근 12개월) -->
|
||||
<!-- 순자산 추이 (최근 3개월, 주간) -->
|
||||
<div class="panel">
|
||||
<h2>순자산 추이 <span class="sub-note">최근 12개월</span></h2>
|
||||
<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"
|
||||
@@ -394,7 +445,7 @@ onMounted(async () => {
|
||||
@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.label }}</span>
|
||||
<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>
|
||||
@@ -523,6 +574,21 @@ h2 {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user