Files
sb-front/src/views/account/AccountDashboardView.vue
T

929 lines
26 KiB
Vue
Raw Normal View History

<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 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 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 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 = {}
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 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 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 = []
}
}
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
} 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">
<h1>통계</h1>
</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 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>
<!-- 분류별 파이차트 -->
<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>
<!-- 순자산 추이 (최근 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;
}
.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;
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;
}
.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>