Files
sb-front/src/views/account/RecurringView.vue
T
ByungCheol 13308a82ef feat: 가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
- 가계부: 대시보드(예산 대비 지출·분류별 파이·기간별 막대·순자산 추이),
  내역(검색·필터·태그), 계좌(은행/카드/대출/투자), 예산, 분류, 정기 거래,
  태그, 투자 포트폴리오(종목·매수/매도·평가손익)
- 게시판: 목록/상세/작성(마크다운)/댓글/태그/열람제한
- 공통: 인증(Bearer 토큰·401 처리), 모바일 반응형(드로어·아이콘 버튼),
  Slim Budget 브랜딩(로고/타이틀/푸터), 홈 대시보드 자리
- 문서: docs/FRONTEND.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:42:52 +09:00

468 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>