1a85e448aa
Deploy / deploy (push) Failing after 11m25s
- 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>
1211 lines
35 KiB
Vue
1211 lines
35 KiB
Vue
<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>
|