feat: 가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
- 가계부: 대시보드(예산 대비 지출·분류별 파이·기간별 막대·순자산 추이), 내역(검색·필터·태그), 계좌(은행/카드/대출/투자), 예산, 분류, 정기 거래, 태그, 투자 포트폴리오(종목·매수/매도·평가손익) - 게시판: 목록/상세/작성(마크다운)/댓글/태그/열람제한 - 공통: 인증(Bearer 토큰·401 처리), 모바일 반응형(드로어·아이콘 버튼), Slim Budget 브랜딩(로고/타이틀/푸터), 홈 대시보드 자리 - 문서: docs/FRONTEND.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
<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
|
||||
}
|
||||
|
||||
/* ===== 분류별 파이차트 ===== */
|
||||
const PIE_R = 52
|
||||
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">
|
||||
<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 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 === 'DAY' }" @click="setUnit('DAY')">일별</button>
|
||||
<button type="button" :class="{ active: unit === 'WEEK' }" @click="setUnit('WEEK')">주별</button>
|
||||
<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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const newName = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
tags.value = await accountApi.tags()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTag() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.createTag({ name })
|
||||
newName.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTag(tag) {
|
||||
const name = (tag.name || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.updateTag(tag.id, { name })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag(tag) {
|
||||
if (!confirm(`'${tag.name}' 태그를 삭제할까요? (내역에서 연결도 해제됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeTag(tag.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tag-admin">
|
||||
<header class="head">
|
||||
<h1>가계부 태그 관리</h1>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</header>
|
||||
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
||||
|
||||
<form class="new-tag" @submit.prevent="addTag">
|
||||
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 고정지출)" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="tags.length" class="tag-list">
|
||||
<li v-for="t in tags" :key="t.id" class="tag-row">
|
||||
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" />
|
||||
<IconBtn icon="check" title="저장" @click="saveTag(t)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 태그가 없습니다.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-admin {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.new-tag {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.new-tag input {
|
||||
flex: 1;
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tag-name {
|
||||
flex: 1;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,898 @@
|
||||
<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 entries = ref([])
|
||||
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 검색·필터
|
||||
const filterOpen = ref(false)
|
||||
const filters = reactive({ keyword: '', type: '', category: '', walletId: '', tagId: '' })
|
||||
const hasFilter = computed(() => !!(filters.keyword || filters.type || filters.category || filters.walletId || filters.tagId))
|
||||
function filterParams() {
|
||||
const p = {}
|
||||
if (filters.keyword) p.keyword = filters.keyword.trim()
|
||||
if (filters.type) p.type = filters.type
|
||||
if (filters.category) p.category = filters.category
|
||||
if (filters.walletId) p.walletId = filters.walletId
|
||||
if (filters.tagId) p.tagId = filters.tagId
|
||||
return p
|
||||
}
|
||||
function applyFilters() {
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.type = ''
|
||||
filters.category = ''
|
||||
filters.walletId = ''
|
||||
filters.tagId = ''
|
||||
load()
|
||||
}
|
||||
|
||||
// 추가/수정 모달
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
|
||||
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
||||
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 계좌/카드
|
||||
const wallets = ref([])
|
||||
async function loadWallets() {
|
||||
try {
|
||||
wallets.value = await accountApi.wallets()
|
||||
} catch {
|
||||
wallets.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 분류(카테고리)
|
||||
const categories = ref([])
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
// 필터용 전체 분류명 (중복 제거)
|
||||
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
||||
|
||||
// 모달 내 분류 인라인 추가
|
||||
const addingCategory = ref(false)
|
||||
const newCategoryName = ref('')
|
||||
const catSubmitting = ref(false)
|
||||
function startAddCategory() {
|
||||
newCategoryName.value = ''
|
||||
addingCategory.value = true
|
||||
}
|
||||
function cancelAddCategory() {
|
||||
addingCategory.value = false
|
||||
newCategoryName.value = ''
|
||||
}
|
||||
async function confirmAddCategory() {
|
||||
const name = newCategoryName.value.trim()
|
||||
if (!name) return
|
||||
catSubmitting.value = true
|
||||
try {
|
||||
await accountApi.createCategory({ type: form.type, name })
|
||||
await loadCategories()
|
||||
form.category = name
|
||||
cancelAddCategory()
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
// 이미 있는 분류면 그대로 선택
|
||||
form.category = name
|
||||
cancelAddCategory()
|
||||
} else {
|
||||
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
catSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 태그 (태그 관리에 등록된 태그 선택)
|
||||
const tagOptions = ref([]) // [{ id, name }]
|
||||
const selectedTagIds = ref([])
|
||||
|
||||
function toggleTag(id) {
|
||||
const i = selectedTagIds.value.indexOf(id)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(id)
|
||||
}
|
||||
|
||||
async function loadTagOptions() {
|
||||
try {
|
||||
tagOptions.value = await accountApi.tags()
|
||||
} catch {
|
||||
tagOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? value : `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
function dotClass(type) {
|
||||
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
||||
}
|
||||
function amountClass(type) {
|
||||
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
||||
}
|
||||
function amountSign(type) {
|
||||
return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const listParams = { year: year.value, month: month.value, ...filterParams() }
|
||||
const sumParams = { year: year.value, month: month.value }
|
||||
const [list, sum] = await Promise.all([accountApi.list(listParams), accountApi.summary(sumParams)])
|
||||
entries.value = list
|
||||
summary.value = sum
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '내역을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
|
||||
selectedTagIds.value = []
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(e) {
|
||||
editId.value = e.id
|
||||
Object.assign(form, {
|
||||
entryDate: e.entryDate,
|
||||
type: e.type,
|
||||
category: e.category || '',
|
||||
amount: e.amount,
|
||||
memo: e.memo || '',
|
||||
walletId: e.walletId || '',
|
||||
toWalletId: e.toWalletId || '',
|
||||
})
|
||||
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
||||
const nameToId = {}
|
||||
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
|
||||
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.entryDate) {
|
||||
formError.value = '거래일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
// 상환/납부: 원금=이체, 이자=지출 자동 분리
|
||||
if (isRepayment.value) {
|
||||
if (!form.walletId || !form.toWalletId) {
|
||||
formError.value = '출금 계좌와 대상(대출/카드)을 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (form.walletId === form.toWalletId) {
|
||||
formError.value = '출금/대상 계좌가 같을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
const principal = Number(form.principal) || 0
|
||||
const interest = Number(form.interest) || 0
|
||||
if (principal <= 0 && interest <= 0) {
|
||||
formError.value = '원금 또는 이자를 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await accountApi.repayment({
|
||||
entryDate: form.entryDate,
|
||||
fromWalletId: form.walletId,
|
||||
targetWalletId: form.toWalletId,
|
||||
principal,
|
||||
interest,
|
||||
memo: form.memo || null,
|
||||
})
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (form.amount == null || form.amount < 0) {
|
||||
formError.value = '금액을 올바르게 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (form.type === 'TRANSFER') {
|
||||
if (!form.walletId || !form.toWalletId) {
|
||||
formError.value = '이체는 출금/입금 계좌를 모두 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (form.walletId === form.toWalletId) {
|
||||
formError.value = '출금/입금 계좌가 같을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
entryDate: form.entryDate,
|
||||
type: form.type,
|
||||
category: form.category || null,
|
||||
amount: Number(form.amount),
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.update(editId.value, payload)
|
||||
else await accountApi.create(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(e) {
|
||||
if (!confirm('이 내역을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await accountApi.remove(e.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
||||
}
|
||||
load()
|
||||
loadTagOptions()
|
||||
loadWallets()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="account">
|
||||
<header class="account-head">
|
||||
<h1>가계부</h1>
|
||||
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
|
||||
</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" />
|
||||
<IconBtn
|
||||
icon="filter" title="검색·필터" class="filter-toggle"
|
||||
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filterOpen" class="filter-bar">
|
||||
<input
|
||||
v-model="filters.keyword" type="text" class="f-keyword"
|
||||
placeholder="메모·분류 검색" @keyup.enter="applyFilters"
|
||||
/>
|
||||
<select v-model="filters.type">
|
||||
<option value="">구분 전체</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
<select v-model="filters.category">
|
||||
<option value="">분류 전체</option>
|
||||
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="filters.walletId">
|
||||
<option value="">계좌 전체</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
<select v-model="filters.tagId">
|
||||
<option value="">태그 전체</option>
|
||||
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
|
||||
<IconBtn icon="refresh" title="초기화" @click="resetFilters" />
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card income">
|
||||
<span class="label">수입</span>
|
||||
<span class="value">+{{ won(summary.totalIncome) }}</span>
|
||||
</div>
|
||||
<div class="card expense">
|
||||
<span class="label">지출</span>
|
||||
<span class="value">-{{ won(summary.totalExpense) }}</span>
|
||||
</div>
|
||||
<div class="card balance">
|
||||
<span class="label">잔액</span>
|
||||
<span class="value">{{ won(summary.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<table v-else-if="entries.length" class="entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-date">날짜</th>
|
||||
<th class="col-cat">분류</th>
|
||||
<th>메모</th>
|
||||
<th class="col-amount">금액</th>
|
||||
<th class="col-act"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in entries" :key="e.id">
|
||||
<td class="col-date">{{ formatDate(e.entryDate) }}</td>
|
||||
<td class="col-cat">
|
||||
<span class="type-dot" :class="dotClass(e.type)"></span>
|
||||
<template v-if="e.type === 'TRANSFER'">이체</template>
|
||||
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
|
||||
</td>
|
||||
<td class="memo">
|
||||
<span v-if="e.type === 'TRANSFER'" class="row-wallet">{{ e.walletName }} → {{ e.toWalletName }}</span>
|
||||
<span v-else-if="e.walletName" class="row-wallet">{{ e.walletName }}</span>
|
||||
{{ e.memo }}
|
||||
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
||||
</td>
|
||||
<td class="col-amount" :class="amountClass(e.type)">
|
||||
{{ amountSign(e.type) }}{{ won(e.amount) }}
|
||||
</td>
|
||||
<td class="col-act">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||
|
||||
<form class="entry-form" @submit.prevent="submit">
|
||||
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
|
||||
<select v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type === 'TRANSFER'">입금 계좌
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 -->
|
||||
<template v-if="isRepayment">
|
||||
<label>대상(대출/카드)
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
</template>
|
||||
|
||||
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
||||
<div v-if="!addingCategory" class="cat-input">
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
v-model="newCategoryName" type="text"
|
||||
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
|
||||
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
|
||||
/>
|
||||
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
|
||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
|
||||
</div>
|
||||
</label>
|
||||
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<div v-if="!isRepayment" class="tag-field">
|
||||
<span class="tag-label">태그</span>
|
||||
<div class="tag-badges">
|
||||
<button
|
||||
v-for="t in tagOptions"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tag-badge"
|
||||
:class="{ active: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>{{ t.name }}</button>
|
||||
<span v-if="!tagOptions.length" class="tag-empty">등록된 태그가 없습니다 (태그 관리에서 추가)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.account-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.period {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.filter-toggle {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
.filter-toggle.on {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.filter-badge {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.filter-bar input,
|
||||
.filter-bar select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-bar .f-keyword {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.card.income .value {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.card.expense .value {
|
||||
color: #c0392b;
|
||||
}
|
||||
.entry-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.entry-table th,
|
||||
.entry-table td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.col-date {
|
||||
width: 60px;
|
||||
}
|
||||
.col-cat {
|
||||
width: 120px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-amount {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-amount.income {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.col-amount.expense {
|
||||
color: #c0392b;
|
||||
}
|
||||
.col-act {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-act button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.type-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.type-dot.income {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.type-dot.expense {
|
||||
background: #c0392b;
|
||||
}
|
||||
.type-dot.transfer {
|
||||
background: #888;
|
||||
}
|
||||
.col-amount.transfer {
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.memo {
|
||||
color: var(--color-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.entry-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.entry-form input,
|
||||
.entry-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cat-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cat-input select,
|
||||
.cat-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cat-add-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-add-btn.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.tag-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tag-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tag-badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-badge.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.12);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-empty {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.row-tag {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.row-wallet {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 내역 표를 2줄(분류·금액 / 날짜·메모·액션)로 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.account {
|
||||
max-width: 100%;
|
||||
}
|
||||
.summary {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card {
|
||||
padding: 0.6rem 0.7rem;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.entry-table thead {
|
||||
display: none;
|
||||
}
|
||||
.entry-table,
|
||||
.entry-table tbody,
|
||||
.entry-table tr,
|
||||
.entry-table td {
|
||||
display: block;
|
||||
}
|
||||
.entry-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.15rem;
|
||||
padding: 0.6rem 0.1rem;
|
||||
}
|
||||
.entry-table td {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
.entry-table .col-cat {
|
||||
order: 0;
|
||||
flex: 1;
|
||||
white-space: normal;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.entry-table .col-amount {
|
||||
order: 1;
|
||||
width: auto;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.entry-table .col-date {
|
||||
order: 2;
|
||||
width: auto;
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.entry-table .memo {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.entry-table .col-act {
|
||||
order: 4;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter-bar .f-keyword {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,659 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import InvestPortfolio from './InvestPortfolio.vue'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const TABS = [
|
||||
{ key: 'BANK', label: '은행계좌' },
|
||||
{ key: 'CARD', label: '신용/체크카드' },
|
||||
{ key: 'LOAN', label: '대출' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
|
||||
const wallets = ref([])
|
||||
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('BANK')
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
|
||||
const form = reactive({
|
||||
type: 'BANK',
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 드롭다운(계좌별 내역)
|
||||
const expandedId = ref(null)
|
||||
const entriesByWallet = reactive({})
|
||||
const loadingEntries = ref(false)
|
||||
|
||||
const filtered = computed(() => wallets.value.filter((w) => w.type === activeType.value))
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
|
||||
|
||||
async function toggleExpand(w) {
|
||||
if (expandedId.value === w.id) {
|
||||
expandedId.value = null
|
||||
return
|
||||
}
|
||||
expandedId.value = w.id
|
||||
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
|
||||
loadingEntries.value = true
|
||||
try {
|
||||
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
|
||||
} catch {
|
||||
entriesByWallet[w.id] = []
|
||||
} finally {
|
||||
loadingEntries.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
|
||||
function effectAmount(e, walletId) {
|
||||
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
|
||||
return e.type === 'INCOME' ? e.amount : -e.amount
|
||||
}
|
||||
function entryDesc(e, w) {
|
||||
const m = e.memo ? ` · ${e.memo}` : ''
|
||||
if (e.type === 'TRANSFER') {
|
||||
if (e.toWalletId === w.id) {
|
||||
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
|
||||
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
|
||||
return `${inLabel} ← ${e.walletName || '계좌'}${m}`
|
||||
}
|
||||
return `이체 → ${e.toWalletName || '계좌'}${m}`
|
||||
}
|
||||
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
|
||||
return `${e.category || '지출'}${m}`
|
||||
}
|
||||
function entryDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime())
|
||||
? v
|
||||
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function issuerLabel(t) {
|
||||
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
}
|
||||
function openingLabel(t) {
|
||||
return t === 'BANK' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '초기 투입원금' : '대출 잔액(원금)'
|
||||
}
|
||||
// 투자 수익률(%) — 투입원금 대비 평가손익
|
||||
function returnPct(w) {
|
||||
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
|
||||
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [ws, nw] = await Promise.all([accountApi.wallets(), accountApi.netWorth()])
|
||||
wallets.value = ws
|
||||
networth.value = nw
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
type: activeType.value,
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(w) {
|
||||
editId.value = w.id
|
||||
Object.assign(form, {
|
||||
type: w.type,
|
||||
name: w.name,
|
||||
issuer: w.issuer || '',
|
||||
accountNumber: w.accountNumber || '',
|
||||
cardType: w.cardType || 'CREDIT',
|
||||
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
|
||||
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
||||
openingDate: w.openingDate || '',
|
||||
currentValue: w.currentValue ?? null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.name.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const magnitude = Number(form.openingBalance) || 0
|
||||
const payload = {
|
||||
type: form.type,
|
||||
name: form.name.trim(),
|
||||
issuer: form.issuer || null,
|
||||
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
|
||||
cardType: form.type === 'CARD' ? form.cardType : null,
|
||||
// 부채는 음수로 저장 (갚을 금액)
|
||||
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
|
||||
openingDate: form.openingDate || null,
|
||||
currentValue:
|
||||
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
||||
? Number(form.currentValue)
|
||||
: null,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateWallet(editId.value, payload)
|
||||
else await accountApi.createWallet(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(w) {
|
||||
if (!confirm(`'${w.name}' 을(를) 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeWallet(w.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeLabel(v) {
|
||||
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wallet">
|
||||
<header class="head">
|
||||
<h1>계좌 관리</h1>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</header>
|
||||
|
||||
<!-- 순자산 요약 -->
|
||||
<div class="networth">
|
||||
<div class="nw-card">
|
||||
<span class="label">총자산</span>
|
||||
<span class="value asset">{{ won(networth.totalAssets) }}</span>
|
||||
</div>
|
||||
<div class="nw-card">
|
||||
<span class="label">총부채</span>
|
||||
<span class="value debt">{{ won(networth.totalLiabilities) }}</span>
|
||||
</div>
|
||||
<div class="nw-card">
|
||||
<span class="label">순자산</span>
|
||||
<span class="value">{{ won(networth.netWorth) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
:class="{ active: activeType === t.key }"
|
||||
@click="activeType = t.key"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<IconBtn icon="plus" title="추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="filtered.length" class="wallet-list">
|
||||
<li v-for="w in filtered" :key="w.id" class="wallet-item">
|
||||
<div class="wallet-row" @click="toggleExpand(w)">
|
||||
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="name">{{ w.name }}</span>
|
||||
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ w.issuer }}
|
||||
<template v-if="w.type === 'BANK' && w.accountNumber"> · {{ w.accountNumber }}</template>
|
||||
<template v-if="w.type === 'INVEST'"> · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="balance-wrap">
|
||||
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
|
||||
<div
|
||||
v-if="w.type === 'INVEST' && w.valuationGain != null"
|
||||
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
|
||||
>
|
||||
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" @click.stop>
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운: 투자는 포트폴리오, 그 외는 계좌별 내역 -->
|
||||
<div v-if="expandedId === w.id" class="entry-drop">
|
||||
<InvestPortfolio v-if="w.type === 'INVEST'" :wallet-id="w.id" @changed="load" />
|
||||
<template v-else>
|
||||
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
|
||||
<ul v-else-if="(entriesByWallet[w.id] || []).length" class="drop-list">
|
||||
<li v-for="e in entriesByWallet[w.id]" :key="e.id" class="drop-row">
|
||||
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
|
||||
<span class="d-desc">{{ entryDesc(e, w) }}</span>
|
||||
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
|
||||
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 항목이 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="wallet-form" @submit.prevent="submit">
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label>{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'CARD'">카드 종류
|
||||
<select v-model="form.cardType" :disabled="submitting">
|
||||
<option value="CREDIT">신용카드</option>
|
||||
<option value="CHECK">체크카드</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<p v-if="form.type === 'INVEST'" class="form-hint">
|
||||
증권계좌 입금은 은행→투자 ‘이체’로 기록하세요. 계좌를 펼치면 <b>종목·매수/매도·현재가</b>를 관리할 수 있고, 평가금액·손익은 자동 계산됩니다.
|
||||
</p>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wallet {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.networth {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.nw-card {
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.nw-card .label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nw-card .value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.nw-card .value.asset {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.nw-card .value.debt {
|
||||
color: #c0392b;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.wallet-list {
|
||||
list-style: none;
|
||||
}
|
||||
.wallet-item {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.wallet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wallet-row:hover {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.chevron {
|
||||
width: 1rem;
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.entry-drop {
|
||||
padding: 0.25rem 0 0.75rem 1.6rem;
|
||||
}
|
||||
.drop-msg {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.drop-list {
|
||||
list-style: none;
|
||||
}
|
||||
.drop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.d-date {
|
||||
width: 3rem;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d-amount {
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.d-amount.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.d-amount.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.balance-wrap {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.gain {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gain.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.gain.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.65;
|
||||
margin: -0.2rem 0 0.1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wallet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.wallet-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.wallet-form input,
|
||||
.wallet-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wallet {
|
||||
max-width: 100%;
|
||||
}
|
||||
.networth {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nw-card {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.nw-card .value {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 0.55rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
/* 한 줄 유지: 이름(축소) · 잔액 · 아이콘 */
|
||||
.wallet-row {
|
||||
column-gap: 0.4rem;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 이름은 한 줄(말줄임), sub(예수금·주식 등)는 자연 줄바꿈 — 손익과 겹치지 않게 */
|
||||
.line1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub {
|
||||
word-break: break-all;
|
||||
}
|
||||
.wallet-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.balance {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.balance-wrap {
|
||||
flex-shrink: 0;
|
||||
padding-left: 0.3rem;
|
||||
}
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,544 @@
|
||||
<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 statuses = ref([])
|
||||
const budgetsRaw = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 분류(지출) 드롭다운
|
||||
const categories = ref([])
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
|
||||
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 daysInYear(y) {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
|
||||
}
|
||||
function baseDays(unit) {
|
||||
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
|
||||
}
|
||||
|
||||
// 고정 모드 자동 환산 미리보기 (현재 월/년 실제 일수 기준)
|
||||
const fixedPreview = computed(() => {
|
||||
if (!form.fixed) return null
|
||||
const amount = Number(form.baseAmount) || 0
|
||||
const daily = amount / baseDays(form.baseUnit)
|
||||
return {
|
||||
weekly: Math.round(daily * 7),
|
||||
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
|
||||
yearly: Math.round(daily * daysInYear(year.value)),
|
||||
}
|
||||
})
|
||||
|
||||
function ratio(s) {
|
||||
if (!s.monthlyBudget) return 0
|
||||
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
|
||||
}
|
||||
function barClass(s) {
|
||||
const r = s.monthlyBudget ? s.spent / s.monthlyBudget : 0
|
||||
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = { year: year.value, month: month.value }
|
||||
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
|
||||
statuses.value = st
|
||||
budgetsRaw.value = raw
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(s) {
|
||||
const b = budgetsRaw.value.find((x) => x.id === s.id)
|
||||
if (!b) return
|
||||
editId.value = b.id
|
||||
Object.assign(form, {
|
||||
category: b.category,
|
||||
fixed: b.fixed,
|
||||
baseUnit: b.baseUnit || 'DAY',
|
||||
baseAmount: b.baseAmount,
|
||||
daily: b.daily,
|
||||
weekly: b.weekly,
|
||||
monthly: b.monthly,
|
||||
yearly: b.yearly,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.category.trim()) {
|
||||
formError.value = '카테고리를 입력하세요.'
|
||||
return
|
||||
}
|
||||
let payload
|
||||
if (form.fixed) {
|
||||
if (!form.baseAmount || form.baseAmount <= 0) {
|
||||
formError.value = '기준 금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
|
||||
} else {
|
||||
payload = {
|
||||
category: form.category.trim(),
|
||||
fixed: false,
|
||||
daily: form.daily != null ? Number(form.daily) : null,
|
||||
weekly: form.weekly != null ? Number(form.weekly) : null,
|
||||
monthly: form.monthly != null ? Number(form.monthly) : null,
|
||||
yearly: form.yearly != null ? Number(form.yearly) : null,
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editId.value) await accountApi.updateBudget(editId.value, payload)
|
||||
else await accountApi.createBudget(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s) {
|
||||
if (!confirm(`'${s.category}' 예산을 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeBudget(s.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="budget">
|
||||
<header class="head">
|
||||
<h1>예산 설정</h1>
|
||||
<div class="head-actions">
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<ul v-else-if="statuses.length" class="status-list">
|
||||
<li v-for="s in statuses" :key="s.id" class="status-row">
|
||||
<div class="srow-top">
|
||||
<span class="cat">{{ s.category }}<span v-if="s.fixed" class="fixed-badge">고정</span></span>
|
||||
<span class="amounts">
|
||||
<span :class="s.remaining < 0 ? 'neg' : ''">{{ won(s.spent) }}</span>
|
||||
/ {{ won(s.monthlyBudget) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" :class="barClass(s)" :style="{ width: Math.min(ratio(s), 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="srow-bottom">
|
||||
<span class="ratio" :class="barClass(s)">{{ ratio(s) }}%</span>
|
||||
<span class="remain">
|
||||
{{ s.remaining >= 0 ? `잔여 ${won(s.remaining)}` : `초과 ${won(-s.remaining)}` }}
|
||||
</span>
|
||||
<span class="acts">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(s)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(s)" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가"로 시작하세요.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="budget-form" @submit.prevent="submit">
|
||||
<label>카테고리
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mode">
|
||||
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
|
||||
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
|
||||
</div>
|
||||
|
||||
<!-- 고정 -->
|
||||
<template v-if="form.fixed">
|
||||
<div class="fixed-base">
|
||||
<select v-model="form.baseUnit" :disabled="submitting">
|
||||
<option value="DAY">일</option>
|
||||
<option value="WEEK">주</option>
|
||||
<option value="MONTH">월</option>
|
||||
</select>
|
||||
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
|
||||
</div>
|
||||
<p v-if="fixedPreview" class="preview">
|
||||
환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
|
||||
<span class="note">(이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 비고정 -->
|
||||
<template v-else>
|
||||
<label>일 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>주 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>년 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.budget {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.period {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.status-list {
|
||||
list-style: none;
|
||||
}
|
||||
.status-row {
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.srow-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.cat {
|
||||
font-weight: 600;
|
||||
}
|
||||
.fixed-badge {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.amounts {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.amounts .neg {
|
||||
color: #c0392b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bar {
|
||||
height: 8px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.bar-fill.ok {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.bar-fill.warn {
|
||||
background: #e67e22;
|
||||
}
|
||||
.bar-fill.over {
|
||||
background: #c0392b;
|
||||
}
|
||||
.srow-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ratio.ok {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.ratio.warn {
|
||||
color: #e67e22;
|
||||
}
|
||||
.ratio.over {
|
||||
color: #c0392b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.remain {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.acts {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.acts button {
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.budget-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.budget-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.budget-form input,
|
||||
.budget-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.radio {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.3rem !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fixed-base {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fixed-base select {
|
||||
width: 5rem;
|
||||
}
|
||||
.fixed-base input {
|
||||
flex: 1;
|
||||
}
|
||||
.preview {
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('EXPENSE') // EXPENSE / INCOME
|
||||
const newName = ref('')
|
||||
|
||||
const filtered = computed(() => categories.value.filter((c) => c.type === activeType.value))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.createCategory({ type: activeType.value, name })
|
||||
newName.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(c) {
|
||||
const name = (c.name || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.updateCategory(c.id, { type: c.type, name })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCategory(c) {
|
||||
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeCategory(c.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function importExisting() {
|
||||
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return
|
||||
try {
|
||||
categories.value = await accountApi.importCategories()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '불러오기에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="cat">
|
||||
<header class="head">
|
||||
<h1>분류 관리</h1>
|
||||
<div class="head-actions">
|
||||
<button type="button" class="text-btn" @click="importExisting">기존 분류 불러오기</button>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. 분류 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
|
||||
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
|
||||
</div>
|
||||
|
||||
<form class="new-cat" @submit.prevent="addCategory">
|
||||
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="filtered.length" class="cat-list">
|
||||
<li v-for="c in filtered" :key="c.id" class="cat-row">
|
||||
<input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
|
||||
<IconBtn icon="check" title="저장" @click="saveCategory(c)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">
|
||||
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cat {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-cat {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.new-cat input {
|
||||
flex: 1;
|
||||
}
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
}
|
||||
.cat-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.cat-name {
|
||||
flex: 1;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const props = defineProps({ walletId: { type: [Number, String], required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const holdings = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 종목 추가/수정 모달
|
||||
const holdingModal = ref(false)
|
||||
const holdingEditId = ref(null)
|
||||
const hForm = reactive({ name: '', ticker: '', currentPrice: null })
|
||||
const hSubmitting = ref(false)
|
||||
const hError = ref(null)
|
||||
|
||||
// 매매 모달
|
||||
const tradeModal = ref(false)
|
||||
const tradeHolding = ref(null)
|
||||
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, fee: null })
|
||||
const tSubmitting = ref(false)
|
||||
const tError = ref(null)
|
||||
|
||||
// 매매이력 드롭다운
|
||||
const openTradesId = ref(null)
|
||||
const tradesByHolding = reactive({})
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function tDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? v : `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
holdings.value = await accountApi.investHoldings(props.walletId)
|
||||
} catch {
|
||||
holdings.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 종목 ===== */
|
||||
function openAddHolding() {
|
||||
holdingEditId.value = null
|
||||
Object.assign(hForm, { name: '', ticker: '', currentPrice: null })
|
||||
hError.value = null
|
||||
holdingModal.value = true
|
||||
}
|
||||
function openEditHolding(h) {
|
||||
holdingEditId.value = h.id
|
||||
Object.assign(hForm, { name: h.name, ticker: h.ticker || '', currentPrice: h.currentPrice ?? null })
|
||||
hError.value = null
|
||||
holdingModal.value = true
|
||||
}
|
||||
async function submitHolding() {
|
||||
hError.value = null
|
||||
if (!hForm.name.trim()) {
|
||||
hError.value = '종목명을 입력하세요.'
|
||||
return
|
||||
}
|
||||
hSubmitting.value = true
|
||||
const payload = {
|
||||
walletId: Number(props.walletId),
|
||||
name: hForm.name.trim(),
|
||||
ticker: hForm.ticker || null,
|
||||
currentPrice: hForm.currentPrice !== '' && hForm.currentPrice != null ? Number(hForm.currentPrice) : null,
|
||||
}
|
||||
try {
|
||||
if (holdingEditId.value) await accountApi.updateHolding(holdingEditId.value, payload)
|
||||
else await accountApi.createHolding(payload)
|
||||
holdingModal.value = false
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
hError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
hSubmitting.value = false
|
||||
}
|
||||
}
|
||||
async function removeHolding(h) {
|
||||
if (!confirm(`'${h.name}' 종목과 매매이력을 모두 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeHolding(h.id)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 매매 ===== */
|
||||
function openTrade(h, type) {
|
||||
tradeHolding.value = h
|
||||
Object.assign(tForm, { tradeType: type, tradeDate: todayStr(), quantity: null, price: h.currentPrice ?? null, fee: null })
|
||||
tError.value = null
|
||||
tradeModal.value = true
|
||||
}
|
||||
async function submitTrade() {
|
||||
tError.value = null
|
||||
if (!tForm.quantity || tForm.quantity <= 0) {
|
||||
tError.value = '수량을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (tForm.price == null || tForm.price < 0) {
|
||||
tError.value = '단가를 입력하세요.'
|
||||
return
|
||||
}
|
||||
tSubmitting.value = true
|
||||
const payload = {
|
||||
tradeType: tForm.tradeType,
|
||||
tradeDate: tForm.tradeDate,
|
||||
quantity: Number(tForm.quantity),
|
||||
price: Number(tForm.price),
|
||||
fee: tForm.fee ? Number(tForm.fee) : 0,
|
||||
}
|
||||
try {
|
||||
await accountApi.addTrade(tradeHolding.value.id, payload)
|
||||
tradeModal.value = false
|
||||
if (openTradesId.value === tradeHolding.value.id) await loadTrades(tradeHolding.value.id)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
tError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
tSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrades(holdingId) {
|
||||
try {
|
||||
tradesByHolding[holdingId] = await accountApi.holdingTrades(holdingId)
|
||||
} catch {
|
||||
tradesByHolding[holdingId] = []
|
||||
}
|
||||
}
|
||||
async function toggleTrades(h) {
|
||||
if (openTradesId.value === h.id) {
|
||||
openTradesId.value = null
|
||||
return
|
||||
}
|
||||
openTradesId.value = h.id
|
||||
await loadTrades(h.id)
|
||||
}
|
||||
async function removeTrade(t, holdingId) {
|
||||
if (!confirm('이 매매 내역을 삭제할까요?')) return
|
||||
try {
|
||||
await accountApi.removeTrade(t.id)
|
||||
await loadTrades(holdingId)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="portfolio">
|
||||
<div class="pf-head">
|
||||
<span class="pf-title">보유 종목</span>
|
||||
<IconBtn icon="plus" title="종목 추가" variant="primary" size="sm" @click="openAddHolding" />
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="pf-msg">불러오는 중...</p>
|
||||
<p v-else-if="!holdings.length" class="pf-msg">보유 종목이 없습니다. 종목을 추가하고 매수를 기록하세요.</p>
|
||||
|
||||
<ul v-else class="hold-list">
|
||||
<li v-for="h in holdings" :key="h.id" class="hold-item">
|
||||
<div class="hold-row">
|
||||
<div class="h-info" @click="toggleTrades(h)">
|
||||
<span class="chev">{{ openTradesId === h.id ? '▾' : '▸' }}</span>
|
||||
<div>
|
||||
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
|
||||
<div class="h-sub">
|
||||
{{ won(h.quantity) }}주 · 평단 {{ won(h.avgPrice) }}
|
||||
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
|
||||
<span v-else class="noprice"> · 현재가 미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-val">
|
||||
<div class="h-eval">{{ won(h.evalValue) }}</div>
|
||||
<div class="h-gain" :class="h.evalGain < 0 ? 'neg' : 'pos'">
|
||||
{{ h.evalGain >= 0 ? '+' : '' }}{{ won(h.evalGain) }}<span v-if="h.returnPct != null"> ({{ h.returnPct }}%)</span>
|
||||
</div>
|
||||
<div v-if="h.realizedPL" class="h-realized">실현 {{ h.realizedPL >= 0 ? '+' : '' }}{{ won(h.realizedPL) }}</div>
|
||||
</div>
|
||||
<div class="h-actions">
|
||||
<IconBtn icon="plus" title="매수" variant="buy" size="sm" @click="openTrade(h, 'BUY')" />
|
||||
<IconBtn icon="minus" title="매도" variant="sell" size="sm" :disabled="h.quantity <= 0" @click="openTrade(h, 'SELL')" />
|
||||
<IconBtn icon="edit" title="현재가 수정" size="sm" @click="openEditHolding(h)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeHolding(h)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 매매이력 -->
|
||||
<div v-if="openTradesId === h.id" class="trade-drop">
|
||||
<ul v-if="(tradesByHolding[h.id] || []).length" class="trade-list">
|
||||
<li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row">
|
||||
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
|
||||
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
|
||||
<span class="t-qty">{{ won(t.quantity) }}주 × {{ won(t.price) }}</span>
|
||||
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
|
||||
<span class="t-amt">{{ won(t.amount) }}</span>
|
||||
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 종목 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="holdingModal = false">×</button>
|
||||
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitHolding">
|
||||
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
|
||||
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="(선택) 예: 005930" :disabled="hSubmitting" /></label>
|
||||
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(수동 갱신)" :disabled="hSubmitting" /></label>
|
||||
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
|
||||
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 매매 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="tradeModal = false">×</button>
|
||||
<h2>{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitTrade">
|
||||
<label>구분
|
||||
<select v-model="tForm.tradeType" :disabled="tSubmitting">
|
||||
<option value="BUY">매수</option>
|
||||
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
|
||||
<label>수량(주)<input v-model.number="tForm.quantity" type="number" min="1" :disabled="tSubmitting" /></label>
|
||||
<label>단가(원)<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" /></label>
|
||||
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
|
||||
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ won(tradeHolding?.quantity) }}주 · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
|
||||
<p v-if="tError" class="pf-msg err">{{ tError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
|
||||
<IconBtn icon="save" title="등록" variant="primary" type="submit" :disabled="tSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portfolio {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
.pf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.pf-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.pf-add {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pf-msg {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.pf-msg.sm {
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.pf-msg.err {
|
||||
color: #c0392b;
|
||||
opacity: 1;
|
||||
}
|
||||
.hold-list {
|
||||
list-style: none;
|
||||
}
|
||||
.hold-item {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.hold-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.h-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chev {
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.h-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.h-ticker {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.h-sub {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.noprice {
|
||||
color: #e67e22;
|
||||
}
|
||||
.h-val {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-eval {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.h-gain {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.h-gain.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.h-gain.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.h-realized {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.h-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.h-actions button {
|
||||
padding: 0.18rem 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.h-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.h-actions .buy {
|
||||
border-color: #2e7d32;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.h-actions .sell {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.h-actions .danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.trade-drop {
|
||||
padding: 0.1rem 0 0.5rem 1.4rem;
|
||||
}
|
||||
.trade-list {
|
||||
list-style: none;
|
||||
}
|
||||
.trade-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.t-date {
|
||||
opacity: 0.7;
|
||||
width: 3rem;
|
||||
}
|
||||
.t-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
.t-type.buy {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.t-type.sell {
|
||||
color: #c0392b;
|
||||
}
|
||||
.t-qty {
|
||||
flex: 1;
|
||||
}
|
||||
.t-fee {
|
||||
opacity: 0.6;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.t-amt {
|
||||
font-weight: 600;
|
||||
}
|
||||
.t-del {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #c0392b;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모달 (계좌 관리와 동일 톤) */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pf-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.pf-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pf-form input,
|
||||
.pf-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pf-hint {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pf-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.pf-buttons button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pf-buttons .primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.hold-row {
|
||||
column-gap: 0.4rem;
|
||||
}
|
||||
.h-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.trade-drop {
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
.trade-row {
|
||||
flex-wrap: wrap;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
.t-date,
|
||||
.t-type,
|
||||
.t-qty,
|
||||
.t-fee,
|
||||
.t-amt {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-date {
|
||||
order: 0;
|
||||
width: auto;
|
||||
}
|
||||
.t-type {
|
||||
order: 1;
|
||||
}
|
||||
.t-amt {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
.t-del {
|
||||
order: 3;
|
||||
}
|
||||
.t-qty {
|
||||
order: 4;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.t-fee {
|
||||
order: 5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<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 recurrings = ref([])
|
||||
const wallets = ref([])
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
type: 'EXPENSE',
|
||||
amount: null,
|
||||
category: '',
|
||||
memo: '',
|
||||
walletId: '',
|
||||
toWalletId: '',
|
||||
frequency: 'MONTHLY',
|
||||
dayOfMonth: 1,
|
||||
dayOfWeek: 1,
|
||||
monthOfYear: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
active: true,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function typeLabel(t) {
|
||||
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
|
||||
}
|
||||
function freqLabel(r) {
|
||||
if (r.frequency === 'DAILY') return '매일'
|
||||
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
|
||||
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}일`
|
||||
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
|
||||
}
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [recs, ws, cats] = await Promise.all([
|
||||
accountApi.recurrings(),
|
||||
accountApi.wallets(),
|
||||
accountApi.categories(),
|
||||
])
|
||||
recurrings.value = recs
|
||||
wallets.value = ws
|
||||
categories.value = cats
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
try {
|
||||
const res = await accountApi.runRecurrings()
|
||||
alert(`${res.generated}건의 정기 거래가 반영되었습니다.`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '반영에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
|
||||
walletId: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
|
||||
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(r) {
|
||||
editId.value = r.id
|
||||
Object.assign(form, {
|
||||
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
|
||||
walletId: r.walletId || '', toWalletId: r.toWalletId || '', frequency: r.frequency,
|
||||
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
|
||||
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.title.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
formError.value = '금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
|
||||
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.startDate) {
|
||||
formError.value = '시작일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
type: form.type,
|
||||
amount: Number(form.amount),
|
||||
category: form.type === 'TRANSFER' ? null : form.category || null,
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
frequency: form.frequency,
|
||||
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
|
||||
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
|
||||
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
active: form.active,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateRecurring(editId.value, payload)
|
||||
else await accountApi.createRecurring(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r) {
|
||||
if (!confirm(`'${r.title}' 정기 거래를 삭제할까요? (이미 생성된 내역은 유지됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeRecurring(r.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="recur">
|
||||
<header class="head">
|
||||
<h1>정기 거래</h1>
|
||||
<div class="head-actions">
|
||||
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
<IconBtn icon="plus" title="정기 거래 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">등록한 주기에 맞춰 가계부 진입 시 자동으로 내역이 생성됩니다. (중복 없이)</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="recurrings.length" class="recur-list">
|
||||
<li v-for="r in recurrings" :key="r.id" class="recur-row" :class="{ inactive: !r.active }">
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span>
|
||||
<span v-if="!r.active" class="off">중지</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ freqLabel(r) }} · {{ won(r.amount) }}
|
||||
<template v-if="r.category"> · {{ r.category }}</template>
|
||||
<template v-if="r.nextDate"> · 다음 {{ r.nextDate }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(r)" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 정기 거래가 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>정기 거래 {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<label>{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}
|
||||
<select v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">(선택 안 함)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type === 'TRANSFER'">입금 계좌
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type !== 'TRANSFER'">분류
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<label>주기
|
||||
<select v-model="form.frequency" :disabled="submitting">
|
||||
<option value="DAILY">매일</option>
|
||||
<option value="WEEKLY">매주</option>
|
||||
<option value="MONTHLY">매월</option>
|
||||
<option value="YEARLY">매년</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'WEEKLY'">요일
|
||||
<select v-model.number="form.dayOfWeek" :disabled="submitting">
|
||||
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recur {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.recur-list {
|
||||
list-style: none;
|
||||
}
|
||||
.recur-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.recur-row.inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.type-badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.type-badge.income {
|
||||
color: #2e7d32;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
.type-badge.expense {
|
||||
color: #c0392b;
|
||||
border-color: #c0392b;
|
||||
}
|
||||
.off {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.83rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.recur-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.recur-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.recur-form label.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.recur-form input,
|
||||
.recur-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.recur-form label.row input {
|
||||
padding: 0;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user