2026-05-31 15:42:52 +09:00
|
|
|
<script setup>
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
import { accountApi } from '@/api/accountApi'
|
|
|
|
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
|
|
|
|
|
|
|
|
|
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 barData = ref([]) // [{bucket, budget, expense}]
|
|
|
|
|
const monthlyStats = ref([]) // MONTH unit of year (income/expense)
|
|
|
|
|
const catType = ref('EXPENSE') // 분류별 파이차트 구분
|
|
|
|
|
const catData = ref([]) // [{category, total}]
|
|
|
|
|
const trendData = ref([]) // [{month, netWorth}]
|
|
|
|
|
|
|
|
|
|
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 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== 분류별 파이차트 ===== */
|
2026-06-03 16:42:07 +09:00
|
|
|
const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60)
|
2026-05-31 15:42:52 +09:00
|
|
|
const PIE_C = 2 * Math.PI * PIE_R
|
|
|
|
|
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
|
|
|
|
|
const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0))
|
|
|
|
|
const pieSegments = computed(() => {
|
|
|
|
|
const total = catTotal.value
|
|
|
|
|
if (total <= 0) return []
|
|
|
|
|
let acc = 0
|
|
|
|
|
return catData.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 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 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 = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function setCatType(t) {
|
|
|
|
|
catType.value = t
|
|
|
|
|
loadCat()
|
|
|
|
|
}
|
|
|
|
|
async function loadTrend() {
|
|
|
|
|
try {
|
|
|
|
|
trendData.value = await accountApi.netWorthTrend({ months: 12 })
|
|
|
|
|
} 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 [status, summary] = await Promise.all([
|
|
|
|
|
accountApi.budgetStatus({ year: year.value, month: month.value }),
|
|
|
|
|
accountApi.summary({ year: year.value, month: month.value }),
|
|
|
|
|
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
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
|
|
|
|
} finally {
|
|
|
|
|
loading.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()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<section class="dash">
|
|
|
|
|
<header class="head">
|
2026-06-03 19:03:56 +09:00
|
|
|
<h1>통계</h1>
|
2026-05-31 15:42:52 +09:00
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 -->
|
|
|
|
|
<div class="top-grid">
|
|
|
|
|
<div class="panel donut-panel">
|
|
|
|
|
<h2>예산 대비 지출</h2>
|
|
|
|
|
<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="panel">
|
|
|
|
|
<h2>수입 대비 지출</h2>
|
|
|
|
|
<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 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>
|
|
|
|
|
</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-else 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>
|
|
|
|
|
|
|
|
|
|
<!-- 순자산 추이 (최근 12개월) -->
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<h2>순자산 추이 <span class="sub-note">최근 12개월</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.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;
|
|
|
|
|
}
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
.donut-wrap {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
.donut {
|
|
|
|
|
width: 120px;
|
|
|
|
|
height: 120px;
|
|
|
|
|
flex-shrink: 0;
|
2026-06-03 16:42:07 +09:00
|
|
|
overflow: visible;
|
2026-05-31 15:42:52 +09:00
|
|
|
}
|
|
|
|
|
.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.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;
|
2026-06-03 16:42:07 +09:00
|
|
|
overflow: visible;
|
2026-05-31 15:42:52 +09:00
|
|
|
}
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
.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>
|