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:
ByungCheol
2026-05-31 15:42:52 +09:00
parent 2485ea05bf
commit 13308a82ef
39 changed files with 7385 additions and 111 deletions
+862
View File
@@ -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>
+153
View File
@@ -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>
+898
View File
@@ -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>
+659
View File
@@ -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>
+544
View File
@@ -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>
+197
View File
@@ -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>
+588
View File
@@ -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>
+467
View File
@@ -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>