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:
+88
-60
@@ -1,86 +1,114 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import SignupModal from '@/components/SignupModal.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
|
||||
// http.js 의 401 처리에서 발생시키는 이벤트 → 세션 정리 후 로그인 팝업
|
||||
function onUnauthorized() {
|
||||
auth.clear()
|
||||
ui.openLogin()
|
||||
}
|
||||
onMounted(() => window.addEventListener('auth:unauthorized', onUnauthorized))
|
||||
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
|
||||
|
||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||
watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen }">
|
||||
<AppHeader class="layout-top" />
|
||||
<AppSidebar class="layout-left" />
|
||||
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
|
||||
<main class="layout-body">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter class="layout-bottom" />
|
||||
</div>
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
<RouterLink to="/users">Users</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<LoginModal />
|
||||
<SignupModal />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'left body'
|
||||
'bottom bottom';
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
.layout-top {
|
||||
grid-area: top;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
.layout-left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
.layout-body {
|
||||
grid-area: body;
|
||||
padding: 1.5rem 2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
.layout-bottom {
|
||||
grid-area: bottom;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
/* 모바일: 사이드바를 오프캔버스 드로어로 */
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
grid-template-areas:
|
||||
'top'
|
||||
'body'
|
||||
'bottom';
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
.layout-left {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1200;
|
||||
width: 78%;
|
||||
max-width: 280px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.22s ease;
|
||||
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
.layout.sidebar-open .layout-left {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
.layout.sidebar-open .sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1150;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.layout-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import http from './http'
|
||||
|
||||
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
|
||||
export const accountApi = {
|
||||
list({ year, month, type, category, walletId, keyword, tagId } = {}) {
|
||||
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
|
||||
},
|
||||
summary({ year, month } = {}) {
|
||||
return http.get('/account/summary', { params: { year, month } })
|
||||
},
|
||||
stats({ unit, year, month } = {}) {
|
||||
return http.get('/account/stats', { params: { unit, year, month } })
|
||||
},
|
||||
categoryStats({ type, year, month } = {}) {
|
||||
return http.get('/account/category-stats', { params: { type, year, month } })
|
||||
},
|
||||
netWorthTrend({ months } = {}) {
|
||||
return http.get('/account/networth/trend', { params: { months } })
|
||||
},
|
||||
create(payload) {
|
||||
return http.post('/account/entries', payload)
|
||||
},
|
||||
update(id, payload) {
|
||||
return http.put(`/account/entries/${id}`, payload)
|
||||
},
|
||||
remove(id) {
|
||||
return http.delete(`/account/entries/${id}`)
|
||||
},
|
||||
|
||||
// 계좌/카드 (사용자별)
|
||||
wallets() {
|
||||
return http.get('/account/wallets')
|
||||
},
|
||||
netWorth() {
|
||||
return http.get('/account/networth')
|
||||
},
|
||||
repayment(payload) {
|
||||
return http.post('/account/repayment', payload)
|
||||
},
|
||||
|
||||
// 정기/반복 거래
|
||||
recurrings() {
|
||||
return http.get('/account/recurrings')
|
||||
},
|
||||
createRecurring(payload) {
|
||||
return http.post('/account/recurrings', payload)
|
||||
},
|
||||
updateRecurring(id, payload) {
|
||||
return http.put(`/account/recurrings/${id}`, payload)
|
||||
},
|
||||
removeRecurring(id) {
|
||||
return http.delete(`/account/recurrings/${id}`)
|
||||
},
|
||||
runRecurrings() {
|
||||
return http.post('/account/recurrings/run')
|
||||
},
|
||||
|
||||
// 예산 (사용자별)
|
||||
budgets() {
|
||||
return http.get('/account/budgets')
|
||||
},
|
||||
budgetStatus({ year, month }) {
|
||||
return http.get('/account/budgets/status', { params: { year, month } })
|
||||
},
|
||||
budgetPeriod({ unit, year, month }) {
|
||||
return http.get('/account/budgets/period', { params: { unit, year, month } })
|
||||
},
|
||||
createBudget(payload) {
|
||||
return http.post('/account/budgets', payload)
|
||||
},
|
||||
updateBudget(id, payload) {
|
||||
return http.put(`/account/budgets/${id}`, payload)
|
||||
},
|
||||
removeBudget(id) {
|
||||
return http.delete(`/account/budgets/${id}`)
|
||||
},
|
||||
createWallet(payload) {
|
||||
return http.post('/account/wallets', payload)
|
||||
},
|
||||
updateWallet(id, payload) {
|
||||
return http.put(`/account/wallets/${id}`, payload)
|
||||
},
|
||||
removeWallet(id) {
|
||||
return http.delete(`/account/wallets/${id}`)
|
||||
},
|
||||
walletEntries(id) {
|
||||
return http.get(`/account/wallets/${id}/entries`)
|
||||
},
|
||||
|
||||
// 투자 포트폴리오 (C) — 종목/매매
|
||||
investHoldings(walletId) {
|
||||
return http.get('/account/invest/holdings', { params: { walletId } })
|
||||
},
|
||||
createHolding(payload) {
|
||||
return http.post('/account/invest/holdings', payload)
|
||||
},
|
||||
updateHolding(id, payload) {
|
||||
return http.put(`/account/invest/holdings/${id}`, payload)
|
||||
},
|
||||
removeHolding(id) {
|
||||
return http.delete(`/account/invest/holdings/${id}`)
|
||||
},
|
||||
holdingTrades(holdingId) {
|
||||
return http.get(`/account/invest/holdings/${holdingId}/trades`)
|
||||
},
|
||||
addTrade(holdingId, payload) {
|
||||
return http.post(`/account/invest/holdings/${holdingId}/trades`, payload)
|
||||
},
|
||||
removeTrade(id) {
|
||||
return http.delete(`/account/invest/trades/${id}`)
|
||||
},
|
||||
|
||||
// 분류(카테고리) — 사용자별
|
||||
categories() {
|
||||
return http.get('/account/categories')
|
||||
},
|
||||
createCategory(payload) {
|
||||
return http.post('/account/categories', payload)
|
||||
},
|
||||
updateCategory(id, payload) {
|
||||
return http.put(`/account/categories/${id}`, payload)
|
||||
},
|
||||
removeCategory(id) {
|
||||
return http.delete(`/account/categories/${id}`)
|
||||
},
|
||||
importCategories() {
|
||||
return http.post('/account/categories/import')
|
||||
},
|
||||
|
||||
// 사용자별 가계부 태그
|
||||
tags() {
|
||||
return http.get('/account/tags')
|
||||
},
|
||||
createTag(payload) {
|
||||
return http.post('/account/tags', payload)
|
||||
},
|
||||
updateTag(id, payload) {
|
||||
return http.put(`/account/tags/${id}`, payload)
|
||||
},
|
||||
removeTag(id) {
|
||||
return http.delete(`/account/tags/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import http from './http'
|
||||
|
||||
// 관리자 태그 관리 API (/api/admin)
|
||||
export const adminApi = {
|
||||
categories() {
|
||||
return http.get('/admin/tag-categories')
|
||||
},
|
||||
createCategory(payload) {
|
||||
return http.post('/admin/tag-categories', payload)
|
||||
},
|
||||
updateCategory(id, payload) {
|
||||
return http.put(`/admin/tag-categories/${id}`, payload)
|
||||
},
|
||||
removeCategory(id) {
|
||||
return http.delete(`/admin/tag-categories/${id}`)
|
||||
},
|
||||
createTag(payload) {
|
||||
return http.post('/admin/tags', payload)
|
||||
},
|
||||
updateTag(id, payload) {
|
||||
return http.put(`/admin/tags/${id}`, payload)
|
||||
},
|
||||
removeTag(id) {
|
||||
return http.delete(`/admin/tags/${id}`)
|
||||
},
|
||||
getBoardSetting() {
|
||||
return http.get('/admin/board-setting')
|
||||
},
|
||||
setBoardSetting(tagCategoryId) {
|
||||
return http.put('/admin/board-setting', { tagCategoryId })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import http from './http'
|
||||
|
||||
// 백엔드 /api/auth 엔드포인트와 매핑
|
||||
export const authApi = {
|
||||
signup(payload) {
|
||||
return http.post('/auth/signup', payload)
|
||||
},
|
||||
login(payload) {
|
||||
return http.post('/auth/login', payload)
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
me() {
|
||||
return http.get('/auth/me')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import http from './http'
|
||||
|
||||
// 백엔드 /api/board 엔드포인트와 매핑
|
||||
export const boardApi = {
|
||||
list({ page = 1, size = 10, tag = '', keyword = '', searchType = '' } = {}) {
|
||||
return http.get('/board/posts', {
|
||||
params: {
|
||||
page,
|
||||
size,
|
||||
tag: tag || undefined,
|
||||
keyword: keyword || undefined,
|
||||
searchType: keyword ? searchType || 'title' : undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
get(id) {
|
||||
return http.get(`/board/posts/${id}`)
|
||||
},
|
||||
create(payload) {
|
||||
return http.post('/board/posts', payload)
|
||||
},
|
||||
update(id, payload) {
|
||||
return http.put(`/board/posts/${id}`, payload)
|
||||
},
|
||||
remove(id) {
|
||||
return http.delete(`/board/posts/${id}`)
|
||||
},
|
||||
tags() {
|
||||
return http.get('/board/tags')
|
||||
},
|
||||
tagGroups() {
|
||||
return http.get('/board/tag-groups')
|
||||
},
|
||||
addComment(id, content) {
|
||||
return http.post(`/board/posts/${id}/comments`, { content })
|
||||
},
|
||||
removeComment(commentId) {
|
||||
return http.delete(`/board/comments/${commentId}`)
|
||||
},
|
||||
block(id, reason) {
|
||||
return http.post(`/board/posts/${id}/block`, { reason })
|
||||
},
|
||||
unblock(id) {
|
||||
return http.post(`/board/posts/${id}/unblock`)
|
||||
},
|
||||
}
|
||||
+4
-2
@@ -27,8 +27,10 @@ http.interceptors.response.use(
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
// 인증 만료 처리 등
|
||||
console.warn('인증이 필요합니다.')
|
||||
// 세션 만료/무효: 클라이언트 저장값 정리 후 로그인 팝업 트리거
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('auth_user')
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
|
||||
+46
-15
@@ -1,9 +1,6 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -12,7 +9,6 @@ a,
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -21,15 +17,50 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
/* ===== Toast UI 코드 스타일 (에디터/뷰어 공통) =====
|
||||
인라인 코드: 다크 배경 + 흰 글자 / 코드 블록: 다크 + 구문 강조(Prism) + Copy 버튼.
|
||||
클래스 중첩으로 우선순위를 높여 라이브러리 기본 스타일을 덮어쓴다. */
|
||||
.toastui-editor-contents.toastui-editor-contents :not(pre) > code {
|
||||
color: #ffffff;
|
||||
background-color: #1e1e1e;
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
.toastui-editor-contents.toastui-editor-contents pre {
|
||||
position: relative;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
overflow: auto;
|
||||
font-size: 100%;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.toastui-editor-contents.toastui-editor-contents pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 100%;
|
||||
color: #f0f0f0;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
/* 코드 블록 Copy 버튼 (JS로 주입되므로 전역 스타일) */
|
||||
.toastui-editor-contents pre .code-copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.toastui-editor-contents pre .code-copy-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 로그인 닫고 회원가입 팝업 열기
|
||||
function goSignup() {
|
||||
ui.openSignup()
|
||||
}
|
||||
|
||||
const form = reactive({ loginId: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 팝업이 열릴 때마다 입력값 초기화
|
||||
watch(
|
||||
() => ui.loginOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
form.loginId = ''
|
||||
form.password = ''
|
||||
error.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = null
|
||||
if (!form.loginId || !form.password) {
|
||||
error.value = '아이디와 비밀번호를 입력하세요.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.loginId, form.password)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
if (redirect) router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleNaverLogin() {
|
||||
alert('네이버 로그인은 준비 중입니다.')
|
||||
}
|
||||
|
||||
// ESC 로 닫기
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape' && ui.loginOpen) ui.closeLogin()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="ui.loginOpen" class="modal-backdrop" @click.self="ui.closeLogin()">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="로그인">
|
||||
<button class="close" type="button" aria-label="닫기" @click="ui.closeLogin()">×</button>
|
||||
<h2>로그인</h2>
|
||||
|
||||
<form class="form" @submit.prevent="handleLogin">
|
||||
<input v-model="form.loginId" type="text" placeholder="아이디" autocomplete="username" :disabled="loading" />
|
||||
<input v-model="form.password" type="password" placeholder="비밀번호" autocomplete="current-password" :disabled="loading" />
|
||||
<button class="submit" type="submit" :disabled="loading">{{ loading ? '로그인 중...' : '로그인' }}</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<button type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
|
||||
<p class="links">
|
||||
계정이 없으신가요?
|
||||
<a href="#" @click.prevent="goSignup">회원가입</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.form input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.submit,
|
||||
.naver {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit:disabled,
|
||||
.naver:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.naver {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
border-color: #03c75a;
|
||||
color: #03c75a;
|
||||
}
|
||||
.links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const form = reactive({ loginId: '', password: '', passwordConfirm: '', name: '', email: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 팝업이 열릴 때마다 입력값 초기화
|
||||
watch(
|
||||
() => ui.signupOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
Object.assign(form, { loginId: '', password: '', passwordConfirm: '', name: '', email: '' })
|
||||
error.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSignup() {
|
||||
error.value = null
|
||||
if (!form.loginId || !form.password || !form.name) {
|
||||
error.value = '아이디, 비밀번호, 이름은 필수입니다.'
|
||||
return
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
error.value = '비밀번호는 8자 이상이어야 합니다.'
|
||||
return
|
||||
}
|
||||
if (form.password !== form.passwordConfirm) {
|
||||
error.value = '비밀번호가 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.signup({
|
||||
loginId: form.loginId,
|
||||
password: form.password,
|
||||
name: form.name,
|
||||
email: form.email || null,
|
||||
})
|
||||
alert('회원가입이 완료되었습니다. 로그인해 주세요.')
|
||||
ui.openLogin() // 회원가입 닫고 로그인 팝업으로 전환
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '회원가입에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape' && ui.signupOpen) ui.closeSignup()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="ui.signupOpen" class="modal-backdrop" @click.self="ui.closeSignup()">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="회원가입">
|
||||
<button class="close" type="button" aria-label="닫기" @click="ui.closeSignup()">×</button>
|
||||
<h2>회원가입</h2>
|
||||
|
||||
<form class="form" @submit.prevent="handleSignup">
|
||||
<input v-model="form.loginId" type="text" placeholder="아이디 (4~50자)" autocomplete="username" :disabled="loading" />
|
||||
<input v-model="form.password" type="password" placeholder="비밀번호 (8자 이상)" autocomplete="new-password" :disabled="loading" />
|
||||
<input v-model="form.passwordConfirm" type="password" placeholder="비밀번호 확인" autocomplete="new-password" :disabled="loading" />
|
||||
<input v-model="form.name" type="text" placeholder="이름" :disabled="loading" />
|
||||
<input v-model="form.email" type="email" placeholder="이메일 (선택)" autocomplete="email" :disabled="loading" />
|
||||
<button class="submit" type="submit" :disabled="loading">{{ loading ? '처리 중...' : '회원가입' }}</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<p class="links">
|
||||
이미 계정이 있으신가요?
|
||||
<a href="#" @click.prevent="ui.openLogin()">로그인</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.form input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.submit {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Editor from '@toast-ui/editor'
|
||||
import '@toast-ui/editor/dist/toastui-editor.css'
|
||||
import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
height: { type: String, default: '400px' },
|
||||
placeholder: { type: String, default: '' },
|
||||
initialEditType: { type: String, default: 'wysiwyg' }, // 'markdown' | 'wysiwyg'
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const el = ref(null)
|
||||
let editor = null
|
||||
|
||||
onMounted(() => {
|
||||
editor = new Editor({
|
||||
el: el.value,
|
||||
height: props.height,
|
||||
initialEditType: props.initialEditType,
|
||||
hideModeSwitch: true, // 마크다운/위지윅 전환 탭 숨김 (WYSIWYG 전용)
|
||||
plugins: [codeSyntaxHighlight], // 코드 구문 강조
|
||||
initialValue: props.modelValue || '',
|
||||
placeholder: props.placeholder,
|
||||
usageStatistics: false,
|
||||
autofocus: false,
|
||||
events: {
|
||||
change: () => emit('update:modelValue', editor.getMarkdown()),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 외부 값 변경(수정 로드, 제출 후 초기화 등)을 에디터에 반영 (입력 중 루프 방지 가드)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (editor && (val || '') !== editor.getMarkdown()) {
|
||||
editor.setMarkdown(val || '')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer'
|
||||
import '@toast-ui/editor/dist/toastui-editor-viewer.css'
|
||||
import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css'
|
||||
|
||||
const props = defineProps({
|
||||
content: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const el = ref(null)
|
||||
let viewer = null
|
||||
|
||||
// 렌더된 코드 블록(pre)에 Copy 버튼 주입
|
||||
function enhanceCodeBlocks() {
|
||||
if (!el.value) return
|
||||
el.value.querySelectorAll('pre').forEach((pre) => {
|
||||
if (pre.querySelector('.code-copy-btn')) return
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'code-copy-btn'
|
||||
btn.textContent = 'Copy'
|
||||
btn.addEventListener('click', () => {
|
||||
const code = pre.querySelector('code')?.innerText ?? pre.innerText
|
||||
navigator.clipboard?.writeText(code).then(() => {
|
||||
btn.textContent = 'Copied!'
|
||||
setTimeout(() => (btn.textContent = 'Copy'), 1500)
|
||||
})
|
||||
})
|
||||
pre.appendChild(btn)
|
||||
})
|
||||
}
|
||||
|
||||
async function render(markdown) {
|
||||
viewer?.setMarkdown(markdown || '')
|
||||
await nextTick()
|
||||
enhanceCodeBlocks()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
viewer = new Viewer({
|
||||
el: el.value,
|
||||
initialValue: props.content || '',
|
||||
plugins: [codeSyntaxHighlight],
|
||||
})
|
||||
await nextTick()
|
||||
enhanceCodeBlocks()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(val) => render(val),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
viewer?.destroy()
|
||||
viewer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<span>© 2026 SlimBudget. All rights reserved.</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.replace('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<RouterLink to="/" class="brand">
|
||||
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<rect width="32" height="32" rx="7" fill="#00bc7e" />
|
||||
<g fill="#ffffff">
|
||||
<rect x="7.5" y="17" width="3.4" height="8" rx="1.7" />
|
||||
<rect x="14.3" y="12.5" width="3.4" height="12.5" rx="1.7" />
|
||||
<rect x="21.1" y="8" width="3.4" height="17" rx="1.7" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-text">SlimBudget</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<span class="username">{{ auth.user?.name || auth.user?.loginId }}님</span>
|
||||
<IconBtn icon="logout" title="로그아웃" @click="handleLogout" />
|
||||
</template>
|
||||
<IconBtn v-else icon="login" title="로그인" variant="primary" @click="ui.openLogin()" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hamburger {
|
||||
display: none;
|
||||
padding: 0.3rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.hamburger svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.brand:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.username {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hamburger {
|
||||
display: inline-flex;
|
||||
}
|
||||
.app-header {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
// 가계부 섹션에 있을 때만 하위 메뉴 노출
|
||||
const inAccount = computed(() => route.path.startsWith('/account'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="app-sidebar">
|
||||
<div class="drawer-head">
|
||||
<span class="drawer-title">메뉴</span>
|
||||
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<RouterLink to="/" class="menu-item">홈</RouterLink>
|
||||
<RouterLink v-if="auth.isAuthenticated" to="/board" class="menu-item">자유게시판</RouterLink>
|
||||
<RouterLink v-if="auth.isAuthenticated" to="/account" class="menu-item">가계부</RouterLink>
|
||||
<template v-if="auth.isAuthenticated && inAccount">
|
||||
<RouterLink to="/account/entries" class="menu-item sub">가계부 내역</RouterLink>
|
||||
<RouterLink to="/account/recurrings" class="menu-item sub">정기 거래</RouterLink>
|
||||
<RouterLink to="/account/wallets" class="menu-item sub">계좌 관리</RouterLink>
|
||||
<RouterLink to="/account/categories" class="menu-item sub">분류 관리</RouterLink>
|
||||
<RouterLink to="/account/budget" class="menu-item sub">예산 설정</RouterLink>
|
||||
<RouterLink to="/account/tags" class="menu-item sub">태그 관리</RouterLink>
|
||||
</template>
|
||||
<RouterLink v-if="auth.isAuthenticated" to="/users" class="menu-item">사용자 관리</RouterLink>
|
||||
<RouterLink v-if="auth.user?.role === 'ADMIN'" to="/admin/tags" class="menu-item">태그 관리</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-sidebar {
|
||||
background: var(--color-background-soft);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.drawer-head {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-sidebar {
|
||||
background: var(--color-background);
|
||||
min-height: 100%;
|
||||
}
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.drawer-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.drawer-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 0.7rem 1.5rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.menu-item.sub {
|
||||
padding: 0.5rem 1.5rem 0.5rem 2.6rem;
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.menu-item.router-link-exact-active {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
border-left: 3px solid hsla(160, 100%, 37%, 1);
|
||||
padding-left: calc(1.5rem - 3px);
|
||||
background: transparent;
|
||||
}
|
||||
.menu-item.sub.router-link-exact-active {
|
||||
padding-left: calc(2.6rem - 3px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
// 공통 아이콘 버튼. 텍스트 대신 아이콘으로 액션 표기 (모바일 대응).
|
||||
// 사용: <IconBtn icon="edit" title="수정" variant="default" @click="..." />
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, required: true },
|
||||
title: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }, // default | primary | danger | ghost | buy | sell
|
||||
size: { type: String, default: 'md' }, // sm | md
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' }, // button | submit
|
||||
})
|
||||
|
||||
// feather 스타일 24x24 stroke 아이콘 path 모음
|
||||
const ICONS = {
|
||||
plus: ['M12 5v14', 'M5 12h14'],
|
||||
minus: ['M5 12h14'],
|
||||
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z'],
|
||||
trash: ['M3 6h18', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6'],
|
||||
check: ['M20 6L9 17l-5-5'],
|
||||
close: ['M18 6L6 18', 'M6 6l12 12'],
|
||||
search: ['M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z', 'M21 21l-4.35-4.35'],
|
||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||
chevronRight: ['M9 18l6-6-6-6'],
|
||||
chevronDown: ['M6 9l6 6 6-6'],
|
||||
refresh: ['M23 4v6h-6', 'M1 20v-6h6', 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10', 'M1 14l4.64 4.36A9 9 0 0 0 20.49 15'],
|
||||
filter: ['M22 3H2l8 9.46V19l4 2v-8.54L22 3z'],
|
||||
menu: ['M3 12h18', 'M3 6h18', 'M3 18h18'],
|
||||
back: ['M19 12H5', 'M12 19l-7-7 7-7'],
|
||||
logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
|
||||
login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'],
|
||||
cart: ['M9 22a1 1 0 1 0 0-2 1 1 0 0 0 0 2z', 'M20 22a1 1 0 1 0 0-2 1 1 0 0 0 0 2z', 'M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6'],
|
||||
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
|
||||
save: ['M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z', 'M17 21v-8H7v8', 'M7 3v5h8'],
|
||||
}
|
||||
|
||||
const paths = computed(() => ICONS[props.icon] || ICONS.plus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="icon-btn"
|
||||
:class="[`v-${variant}`, `s-${size}`]"
|
||||
:title="title"
|
||||
:aria-label="title"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path v-for="(d, i) in paths" :key="i" :d="d" />
|
||||
</svg>
|
||||
<span v-if="$slots.default" class="icon-label"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.icon-btn.s-md svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.icon-btn.s-sm {
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.icon-btn.s-sm svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.icon-label {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.icon-btn.v-primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.icon-btn.v-danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.icon-btn.v-buy {
|
||||
border-color: #2e7d32;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.icon-btn.v-sell {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.icon-btn.v-ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.icon-btn.v-ghost:hover:not(:disabled) {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
</style>
|
||||
+90
-8
@@ -1,5 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -9,20 +11,100 @@ const router = createRouter({
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: () => import('../views/UsersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/board',
|
||||
name: 'board',
|
||||
component: () => import('../views/board/BoardListView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/board/write',
|
||||
name: 'board-write',
|
||||
component: () => import('../views/board/BoardWriteView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/board/:id(\\d+)',
|
||||
name: 'board-detail',
|
||||
component: () => import('../views/board/BoardDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/board/:id(\\d+)/edit',
|
||||
name: 'board-edit',
|
||||
component: () => import('../views/board/BoardWriteView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/tags',
|
||||
name: 'admin-tags',
|
||||
component: () => import('../views/admin/TagAdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: () => import('../views/account/AccountDashboardView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/entries',
|
||||
name: 'account-entries',
|
||||
component: () => import('../views/account/AccountView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/tags',
|
||||
name: 'account-tags',
|
||||
component: () => import('../views/account/AccountTagView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/wallets',
|
||||
name: 'account-wallets',
|
||||
component: () => import('../views/account/AccountWalletView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/categories',
|
||||
name: 'account-categories',
|
||||
component: () => import('../views/account/CategoryView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/budget',
|
||||
name: 'account-budget',
|
||||
component: () => import('../views/account/BudgetView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/recurrings',
|
||||
name: 'account-recurrings',
|
||||
component: () => import('../views/account/RecurringView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 전역 네비게이션 가드
|
||||
router.beforeEach((to, from) => {
|
||||
const auth = useAuthStore()
|
||||
// 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
const ui = useUiStore()
|
||||
ui.openLogin(to.fullPath)
|
||||
return from.name ? false : { name: 'home' }
|
||||
}
|
||||
// 관리자 전용 페이지 — 비관리자는 홈으로
|
||||
if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') {
|
||||
return { name: 'home' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
// 세션은 Redis(서버) + localStorage(클라이언트) 양쪽에 보관한다.
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_KEY = 'auth_user'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
||||
const user = ref(safeParse(localStorage.getItem(USER_KEY)))
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
function persist() {
|
||||
if (token.value) localStorage.setItem(TOKEN_KEY, token.value)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
|
||||
if (user.value) localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||
else localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
async function login(loginId, password) {
|
||||
const res = await authApi.login({ loginId, password })
|
||||
token.value = res.token
|
||||
user.value = res.member
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
async function signup(payload) {
|
||||
return authApi.signup(payload)
|
||||
}
|
||||
|
||||
// 서버 세션 기준으로 현재 사용자 정보 동기화 (토큰 유효성 확인 용도)
|
||||
async function fetchMe() {
|
||||
const me = await authApi.me()
|
||||
user.value = { ...(user.value || {}), ...me }
|
||||
persist()
|
||||
return me
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// 서버 세션이 이미 만료됐어도 클라이언트는 정리
|
||||
}
|
||||
clear()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
persist()
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, signup, fetchMe, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
try {
|
||||
return value ? JSON.parse(value) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const loginOpen = ref(false)
|
||||
const signupOpen = ref(false)
|
||||
const redirectPath = ref('')
|
||||
|
||||
// 모바일 사이드바 드로어
|
||||
const sidebarOpen = ref(false)
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
// 로그인 팝업 (회원가입 팝업은 닫고 전환)
|
||||
function openLogin(redirect = '') {
|
||||
redirectPath.value = redirect || ''
|
||||
signupOpen.value = false
|
||||
loginOpen.value = true
|
||||
}
|
||||
function closeLogin() {
|
||||
loginOpen.value = false
|
||||
redirectPath.value = ''
|
||||
}
|
||||
|
||||
// 회원가입 팝업 (로그인 팝업은 닫고 전환)
|
||||
function openSignup() {
|
||||
loginOpen.value = false
|
||||
signupOpen.value = true
|
||||
}
|
||||
function closeSignup() {
|
||||
signupOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
loginOpen, signupOpen, redirectPath, openLogin, closeLogin, openSignup, closeSignup,
|
||||
sidebarOpen, toggleSidebar, closeSidebar,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// 날짜/시간 표시 유틸
|
||||
|
||||
/**
|
||||
* 1일 이내면 "방금 전 / N분 전 / N시간 전", 그 이상이면 YYYYMMDD 로 표시.
|
||||
*/
|
||||
export function formatRelative(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMin < 1) return '방금 전'
|
||||
if (diffMin < 60) return `${diffMin}분 전`
|
||||
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
if (diffHour < 24) return `${diffHour}시간 전`
|
||||
|
||||
return formatYmd(date)
|
||||
}
|
||||
|
||||
/** YYYYMMDD */
|
||||
export function formatYmd(value) {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+26
-4
@@ -1,9 +1,31 @@
|
||||
<script setup>
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
// 홈 = 대시보드 (현재는 빈 공간 — 추후 콘텐츠 추가 예정)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<section class="dashboard">
|
||||
<div class="placeholder"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40vh;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+52
-5
@@ -2,6 +2,7 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { users, loading, error } = storeToRefs(userStore)
|
||||
@@ -58,14 +59,12 @@ function formatDate(value) {
|
||||
<form class="user-form" @submit.prevent="handleSubmit">
|
||||
<input v-model="form.name" type="text" placeholder="이름" :disabled="submitting" />
|
||||
<input v-model="form.email" type="email" placeholder="이메일" :disabled="submitting" />
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? '등록 중...' : '추가' }}
|
||||
</button>
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" :disabled="submitting" />
|
||||
</form>
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="status">
|
||||
<button type="button" :disabled="loading" @click="userStore.fetchUsers()">새로고침</button>
|
||||
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="userStore.fetchUsers()" />
|
||||
<span v-if="loading">불러오는 중...</span>
|
||||
<span v-else>총 {{ users.length }}명</span>
|
||||
</div>
|
||||
@@ -88,7 +87,7 @@ function formatDate(value) {
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ formatDate(user.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="danger" @click="handleRemove(user)">삭제</button>
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="handleRemove(user)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -173,4 +172,52 @@ button.danger:hover {
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users {
|
||||
padding: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.user-table thead {
|
||||
display: none;
|
||||
}
|
||||
.user-table,
|
||||
.user-table tbody,
|
||||
.user-table tr,
|
||||
.user-table td {
|
||||
display: block;
|
||||
}
|
||||
.user-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.6rem;
|
||||
row-gap: 0.15rem;
|
||||
padding: 0.6rem 0.1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.user-table td {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.user-table td:nth-child(1)::before {
|
||||
content: '#';
|
||||
}
|
||||
.user-table td:nth-child(2) {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.user-table td:nth-child(3) {
|
||||
width: 100%;
|
||||
opacity: 0.75;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.user-table td:nth-child(4) {
|
||||
display: none; /* 생성일은 모바일에서 숨김 */
|
||||
}
|
||||
.user-table td:nth-child(5) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
const unit = ref('MONTH') // DAY / WEEK / MONTH / YEAR
|
||||
const tableYear = ref(now.getFullYear()) // 월별 총액 표 전용 연도
|
||||
const currentYear = now.getFullYear()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const budgetTotal = ref(0)
|
||||
const spentTotal = ref(0)
|
||||
const monthIncome = ref(0)
|
||||
const monthExpense = ref(0)
|
||||
const barData = ref([]) // [{bucket, budget, expense}]
|
||||
const monthlyStats = ref([]) // MONTH unit of year (income/expense)
|
||||
const catType = ref('EXPENSE') // 분류별 파이차트 구분
|
||||
const catData = ref([]) // [{category, total}]
|
||||
const trendData = ref([]) // [{month, netWorth}]
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function daysInMonth(y, m) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
function range(a, b) {
|
||||
const r = []
|
||||
for (let i = a; i <= b; i++) r.push(i)
|
||||
return r
|
||||
}
|
||||
|
||||
/* ===== 예산 대비 지출 도넛 ===== */
|
||||
const R = 52
|
||||
const C = 2 * Math.PI * R
|
||||
const budgetRatio = computed(() => (budgetTotal.value > 0 ? Math.min(spentTotal.value / budgetTotal.value, 1) : 0))
|
||||
const budgetPct = computed(() => (budgetTotal.value > 0 ? Math.round((spentTotal.value / budgetTotal.value) * 100) : 0))
|
||||
const donutDash = computed(() => `${budgetRatio.value * C} ${C}`)
|
||||
const donutColor = computed(() => {
|
||||
const r = budgetTotal.value > 0 ? spentTotal.value / budgetTotal.value : 0
|
||||
return r >= 1 ? '#c0392b' : r >= 0.8 ? '#e67e22' : '#2e7d32'
|
||||
})
|
||||
|
||||
/* ===== 수입/지출 비율 ===== */
|
||||
const ieTotal = computed(() => monthIncome.value + monthExpense.value)
|
||||
const incomePct = computed(() => (ieTotal.value > 0 ? (monthIncome.value / ieTotal.value) * 100 : 0))
|
||||
|
||||
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
|
||||
const bars = computed(() => {
|
||||
const map = {}
|
||||
barData.value.forEach((d) => (map[d.bucket] = d))
|
||||
let buckets
|
||||
if (unit.value === 'DAY') buckets = range(1, daysInMonth(year.value, month.value)).map(String)
|
||||
else if (unit.value === 'WEEK') buckets = range(1, Math.ceil(daysInMonth(year.value, month.value) / 7)).map(String)
|
||||
else if (unit.value === 'MONTH') buckets = range(1, 12).map(String)
|
||||
else buckets = barData.value.map((d) => d.bucket) // YEAR: 존재하는 연도
|
||||
return buckets.map((b) => ({
|
||||
label: unit.value === 'WEEK' ? `${b}주` : b,
|
||||
budget: map[b]?.budget || 0,
|
||||
expense: map[b]?.expense || 0,
|
||||
}))
|
||||
})
|
||||
const barMax = computed(() => Math.max(1, ...bars.value.flatMap((b) => [b.budget, b.expense])))
|
||||
|
||||
/* ===== 막대 차트 툴팁 (지출/예산 비율) ===== */
|
||||
const chartRef = ref(null)
|
||||
const tip = reactive({ show: false, x: 0, y: 0, label: '', budget: 0, expense: 0, ratio: 0 })
|
||||
function showTip(b) {
|
||||
tip.label = b.label
|
||||
tip.budget = b.budget
|
||||
tip.expense = b.expense
|
||||
tip.ratio = b.budget > 0 ? Math.round((b.expense / b.budget) * 100) : 0
|
||||
tip.show = true
|
||||
}
|
||||
function moveTip(e) {
|
||||
const r = chartRef.value?.getBoundingClientRect()
|
||||
if (!r) return
|
||||
tip.x = e.clientX - r.left
|
||||
tip.y = e.clientY - r.top
|
||||
}
|
||||
function hideTip() {
|
||||
tip.show = false
|
||||
}
|
||||
|
||||
/* ===== 분류별 파이차트 ===== */
|
||||
const PIE_R = 52
|
||||
const PIE_C = 2 * Math.PI * PIE_R
|
||||
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
|
||||
const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0))
|
||||
const pieSegments = computed(() => {
|
||||
const total = catTotal.value
|
||||
if (total <= 0) return []
|
||||
let acc = 0
|
||||
return catData.value.map((d, i) => {
|
||||
const len = (d.total / total) * PIE_C
|
||||
const seg = {
|
||||
category: d.category,
|
||||
total: d.total,
|
||||
pct: Math.round((d.total / total) * 100),
|
||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
len,
|
||||
offset: -acc,
|
||||
}
|
||||
acc += len
|
||||
return seg
|
||||
})
|
||||
})
|
||||
const catHover = ref(-1)
|
||||
|
||||
/* ===== 순자산 추이 ===== */
|
||||
const trendView = computed(() => {
|
||||
const data = trendData.value
|
||||
if (!data.length) return { pts: [], line: '', area: '', zeroY: null, min: 0, max: 0 }
|
||||
const vals = data.map((d) => d.netWorth)
|
||||
const min = Math.min(...vals, 0)
|
||||
const max = Math.max(...vals, 0)
|
||||
const span = max - min || 1
|
||||
const n = data.length
|
||||
const pts = data.map((d, i) => ({
|
||||
x: n === 1 ? 50 : (i / (n - 1)) * 100,
|
||||
y: 100 - ((d.netWorth - min) / span) * 100,
|
||||
month: d.month,
|
||||
netWorth: d.netWorth,
|
||||
label: d.month.slice(5), // MM
|
||||
}))
|
||||
const line = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
const area = `${pts[0].x.toFixed(2)},100 ` + line + ` ${pts[n - 1].x.toFixed(2)},100`
|
||||
const zeroY = min < 0 && max > 0 ? 100 - ((0 - min) / span) * 100 : null
|
||||
return { pts, line, area, zeroY, min, max }
|
||||
})
|
||||
const trendTip = reactive({ show: false, x: 0, y: 0, month: '', netWorth: 0 })
|
||||
function showTrendTip(p) {
|
||||
trendTip.month = p.month
|
||||
trendTip.netWorth = p.netWorth
|
||||
trendTip.x = p.x
|
||||
trendTip.y = p.y
|
||||
trendTip.show = true
|
||||
}
|
||||
function hideTrendTip() {
|
||||
trendTip.show = false
|
||||
}
|
||||
|
||||
async function loadCat() {
|
||||
try {
|
||||
catData.value = await accountApi.categoryStats({ type: catType.value, year: year.value, month: month.value })
|
||||
} catch {
|
||||
catData.value = []
|
||||
}
|
||||
}
|
||||
function setCatType(t) {
|
||||
catType.value = t
|
||||
loadCat()
|
||||
}
|
||||
async function loadTrend() {
|
||||
try {
|
||||
trendData.value = await accountApi.netWorthTrend({ months: 12 })
|
||||
} catch {
|
||||
trendData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 월별 총액 목록 ===== */
|
||||
const monthlyRows = computed(() => {
|
||||
const map = {}
|
||||
monthlyStats.value.forEach((d) => (map[d.bucket] = d))
|
||||
return range(1, 12).map((m) => {
|
||||
const d = map[String(m)] || { income: 0, expense: 0 }
|
||||
return { month: m, income: d.income, expense: d.expense, net: d.income - d.expense }
|
||||
})
|
||||
})
|
||||
|
||||
async function loadBar() {
|
||||
try {
|
||||
barData.value = await accountApi.budgetPeriod({ unit: unit.value, year: year.value, month: month.value })
|
||||
} catch {
|
||||
barData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [status, summary] = await Promise.all([
|
||||
accountApi.budgetStatus({ year: year.value, month: month.value }),
|
||||
accountApi.summary({ year: year.value, month: month.value }),
|
||||
loadBar(),
|
||||
loadCat(),
|
||||
])
|
||||
budgetTotal.value = status.reduce((s, x) => s + x.monthlyBudget, 0)
|
||||
spentTotal.value = status.reduce((s, x) => s + x.spent, 0)
|
||||
monthIncome.value = summary.totalIncome
|
||||
monthExpense.value = summary.totalExpense
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setUnit(u) {
|
||||
unit.value = u
|
||||
loadBar()
|
||||
}
|
||||
function prevMonth() {
|
||||
if (month.value === 1) {
|
||||
month.value = 12
|
||||
year.value -= 1
|
||||
} else month.value -= 1
|
||||
load()
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month.value === 12) {
|
||||
month.value = 1
|
||||
year.value += 1
|
||||
} else month.value += 1
|
||||
load()
|
||||
}
|
||||
|
||||
// 월별 총액 표: 전용 연도
|
||||
async function loadMonthly() {
|
||||
try {
|
||||
monthlyStats.value = await accountApi.stats({ unit: 'MONTH', year: tableYear.value })
|
||||
} catch {
|
||||
monthlyStats.value = []
|
||||
}
|
||||
}
|
||||
function prevTableYear() {
|
||||
tableYear.value -= 1
|
||||
loadMonthly()
|
||||
}
|
||||
function nextTableYear() {
|
||||
if (tableYear.value >= currentYear) return
|
||||
tableYear.value += 1
|
||||
loadMonthly()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영 (중복 없이)
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 대시보드 조회를 막지 않는다
|
||||
}
|
||||
load()
|
||||
loadMonthly()
|
||||
loadTrend()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dash">
|
||||
<header class="head">
|
||||
<h1>가계부 대시보드</h1>
|
||||
</header>
|
||||
|
||||
<div class="month-nav">
|
||||
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
||||
<span class="period">{{ periodLabel }}</span>
|
||||
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 -->
|
||||
<div class="top-grid">
|
||||
<div class="panel donut-panel">
|
||||
<h2>예산 대비 지출</h2>
|
||||
<div v-if="budgetTotal > 0" class="donut-wrap">
|
||||
<svg viewBox="0 0 120 120" class="donut">
|
||||
<circle cx="60" cy="60" :r="R" fill="none" stroke="var(--color-background-mute)" stroke-width="14" />
|
||||
<circle
|
||||
cx="60" cy="60" :r="R" fill="none" :stroke="donutColor" stroke-width="14"
|
||||
:stroke-dasharray="donutDash" stroke-linecap="round" transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<text x="60" y="56" text-anchor="middle" class="donut-pct">{{ budgetPct }}%</text>
|
||||
<text x="60" y="74" text-anchor="middle" class="donut-sub">지출/예산</text>
|
||||
</svg>
|
||||
<div class="donut-legend">
|
||||
<div>지출 <b>{{ won(spentTotal) }}</b></div>
|
||||
<div>예산 <b>{{ won(budgetTotal) }}</b></div>
|
||||
<div :class="budgetTotal - spentTotal < 0 ? 'neg' : ''">
|
||||
{{ budgetTotal - spentTotal >= 0 ? '잔여' : '초과' }} <b>{{ won(Math.abs(budgetTotal - spentTotal)) }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">설정된 예산이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>수입 대비 지출</h2>
|
||||
<div class="ie">
|
||||
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
|
||||
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
|
||||
<div class="ie-bar">
|
||||
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
|
||||
</div>
|
||||
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분류별 파이차트 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>분류별 {{ catType === 'EXPENSE' ? '지출' : '수입' }}</h2>
|
||||
<div class="unit-tabs">
|
||||
<button type="button" :class="{ active: catType === 'EXPENSE' }" @click="setCatType('EXPENSE')">지출</button>
|
||||
<button type="button" :class="{ active: catType === 'INCOME' }" @click="setCatType('INCOME')">수입</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pieSegments.length" class="pie-wrap">
|
||||
<svg viewBox="0 0 120 120" class="pie">
|
||||
<circle cx="60" cy="60" :r="PIE_R" fill="none" stroke="var(--color-background-mute)" stroke-width="20" />
|
||||
<circle
|
||||
v-for="(s, i) in pieSegments" :key="i"
|
||||
cx="60" cy="60" :r="PIE_R" fill="none" :stroke="s.color"
|
||||
:stroke-width="catHover === i ? 24 : 20"
|
||||
:stroke-dasharray="`${s.len} ${PIE_C - s.len}`" :stroke-dashoffset="s.offset"
|
||||
transform="rotate(-90 60 60)" class="pie-seg"
|
||||
@mouseenter="catHover = i" @mouseleave="catHover = -1"
|
||||
/>
|
||||
<text x="60" y="57" text-anchor="middle" class="pie-center">{{ won(catTotal) }}</text>
|
||||
<text x="60" y="72" text-anchor="middle" class="pie-center-sub">총 {{ catType === 'EXPENSE' ? '지출' : '수입' }}</text>
|
||||
</svg>
|
||||
<ul class="pie-legend">
|
||||
<li v-for="(s, i) in pieSegments" :key="i" :class="{ hover: catHover === i }"
|
||||
@mouseenter="catHover = i" @mouseleave="catHover = -1">
|
||||
<span class="sw" :style="{ background: s.color }"></span>
|
||||
<span class="cat">{{ s.category }}</span>
|
||||
<span class="pct">{{ s.pct }}%</span>
|
||||
<span class="amt">{{ won(s.total) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 기간별 예산 대비 지출 막대 차트 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>기간별 예산 대비 지출</h2>
|
||||
<div class="unit-tabs">
|
||||
<button type="button" :class="{ active: unit === 'DAY' }" @click="setUnit('DAY')">일별</button>
|
||||
<button type="button" :class="{ active: unit === 'WEEK' }" @click="setUnit('WEEK')">주별</button>
|
||||
<button type="button" :class="{ active: unit === 'MONTH' }" @click="setUnit('MONTH')">월별</button>
|
||||
<button type="button" :class="{ active: unit === 'YEAR' }" @click="setUnit('YEAR')">년별</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-inline">
|
||||
<span class="dot budget"></span>예산 <span class="dot expense"></span>지출
|
||||
</div>
|
||||
<div v-if="bars.length" ref="chartRef" class="chart-wrap" @mousemove="moveTip" @mouseleave="hideTip">
|
||||
<div class="bar-chart">
|
||||
<div v-for="(b, i) in bars" :key="i" class="bar-col" @mouseenter="showTip(b)">
|
||||
<div class="bars">
|
||||
<div class="bar budget" :style="{ height: (b.budget / barMax) * 100 + '%' }"></div>
|
||||
<div class="bar expense" :style="{ height: (b.expense / barMax) * 100 + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-label">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tip.show" class="tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
|
||||
<div class="t-label">{{ tip.label }}{{ unit === 'YEAR' ? '년' : unit === 'MONTH' ? '월' : '' }}</div>
|
||||
<div><span class="dot budget"></span>예산 {{ won(tip.budget) }}</div>
|
||||
<div><span class="dot expense"></span>지출 {{ won(tip.expense) }}</div>
|
||||
<div class="t-ratio" :class="tip.ratio >= 100 ? 'over' : ''">예산 대비 {{ tip.ratio }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">데이터가 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 순자산 추이 (최근 12개월) -->
|
||||
<div class="panel">
|
||||
<h2>순자산 추이 <span class="sub-note">최근 12개월</span></h2>
|
||||
<div v-if="trendView.pts.length" class="trend-wrap" @mouseleave="hideTrendTip">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="trend-svg">
|
||||
<line v-if="trendView.zeroY !== null" x1="0" :y1="trendView.zeroY" x2="100" :y2="trendView.zeroY"
|
||||
class="zero-line" vector-effect="non-scaling-stroke" />
|
||||
<polygon :points="trendView.area" class="trend-area" />
|
||||
<polyline :points="trendView.line" class="trend-line" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<!-- 점/호버 마커 (HTML 오버레이) -->
|
||||
<div
|
||||
v-for="(p, i) in trendView.pts" :key="i" class="trend-dot"
|
||||
:style="{ left: p.x + '%', top: p.y + '%' }"
|
||||
@mouseenter="showTrendTip(p)"
|
||||
></div>
|
||||
<div class="trend-labels">
|
||||
<span v-for="(p, i) in trendView.pts" :key="i" class="t-axis" :style="{ left: p.x + '%' }">{{ p.label }}</span>
|
||||
</div>
|
||||
<div v-if="trendTip.show" class="tooltip trend-tip" :style="{ left: trendTip.x + '%', top: trendTip.y + '%' }">
|
||||
<div class="t-label">{{ trendTip.month }}</div>
|
||||
<div :class="trendTip.netWorth < 0 ? 'neg' : ''">순자산 {{ won(trendTip.netWorth) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">데이터가 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 월별 수입/지출 총액 목록 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>월별 총액</h2>
|
||||
<div class="year-nav">
|
||||
<IconBtn icon="chevronLeft" title="이전 연도" size="sm" @click="prevTableYear" />
|
||||
<span class="year-label">{{ tableYear }}년</span>
|
||||
<IconBtn icon="chevronRight" title="다음 연도" size="sm" :disabled="tableYear >= currentYear" @click="nextTableYear" />
|
||||
</div>
|
||||
</div>
|
||||
<table class="monthly">
|
||||
<thead>
|
||||
<tr><th>월</th><th class="num">수입</th><th class="num">지출</th><th class="num">수지</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in monthlyRows" :key="r.month">
|
||||
<td>{{ r.month }}월</td>
|
||||
<td class="num income">{{ won(r.income) }}</td>
|
||||
<td class="num expense">{{ won(r.expense) }}</td>
|
||||
<td class="num" :class="r.net < 0 ? 'expense' : 'income'">{{ won(r.net) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.month-nav button {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.period {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.top-grid .panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.donut-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.donut {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.donut-pct {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
fill: var(--color-heading);
|
||||
}
|
||||
.donut-sub {
|
||||
font-size: 9px;
|
||||
fill: var(--color-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.donut-legend {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.donut-legend .neg b {
|
||||
color: #c0392b;
|
||||
}
|
||||
.ie-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ie-row.net {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 0.3rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.income {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.expense {
|
||||
color: #c0392b;
|
||||
}
|
||||
.ie-bar {
|
||||
height: 8px;
|
||||
background: #c0392b;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.ie-fill.income {
|
||||
height: 100%;
|
||||
background: #2e7d32;
|
||||
}
|
||||
.unit-tabs {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.unit-tabs button {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.unit-tabs button.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.legend-inline {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.75;
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.dot.income {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.dot.expense {
|
||||
background: #c0392b;
|
||||
}
|
||||
.dot.budget {
|
||||
background: #3b82a6;
|
||||
}
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 160px;
|
||||
overflow-x: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.bar-col {
|
||||
flex: 1;
|
||||
min-width: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.bar {
|
||||
width: 45%;
|
||||
min-height: 1px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.bar.income {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.bar.budget {
|
||||
background: #3b82a6;
|
||||
}
|
||||
.bar.expense {
|
||||
background: #c0392b;
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, calc(-100% - 12px));
|
||||
pointer-events: none;
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
|
||||
z-index: 5;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tooltip .t-label {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.tooltip .dot {
|
||||
margin-left: 0;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
.tooltip .t-ratio {
|
||||
margin-top: 0.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-top: 0.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tooltip .t-ratio.over {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.bar-label {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.2rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 분류별 파이차트 */
|
||||
.pie-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.pie {
|
||||
width: 100%;
|
||||
max-width: 170px;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pie-seg {
|
||||
cursor: pointer;
|
||||
transition: stroke-width 0.12s ease;
|
||||
}
|
||||
.pie-center {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
fill: var(--color-heading);
|
||||
}
|
||||
.pie-center-sub {
|
||||
font-size: 8px;
|
||||
fill: var(--color-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.pie-legend {
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 0.1rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pie-legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pie-legend li.hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.pie-legend .sw {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pie-legend .cat {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pie-legend .pct {
|
||||
opacity: 0.65;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.pie-legend .amt {
|
||||
font-weight: 600;
|
||||
min-width: 5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 순자산 추이 */
|
||||
.sub-note {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.trend-wrap {
|
||||
position: relative;
|
||||
height: 170px;
|
||||
padding-bottom: 1.1rem;
|
||||
}
|
||||
.trend-svg {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
overflow: visible;
|
||||
}
|
||||
.trend-area {
|
||||
fill: hsla(160, 100%, 37%, 0.12);
|
||||
}
|
||||
.trend-line {
|
||||
fill: none;
|
||||
stroke: hsla(160, 100%, 37%, 1);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.zero-line {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.trend-dot {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin: -4.5px 0 0 -4.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-background);
|
||||
border: 2px solid hsla(160, 100%, 37%, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trend-dot:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.trend-labels {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1rem;
|
||||
}
|
||||
.t-axis {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.62rem;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trend-tip {
|
||||
position: absolute;
|
||||
}
|
||||
.trend-tip .neg {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.year-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.year-nav button {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.year-nav button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.year-label {
|
||||
font-weight: 600;
|
||||
min-width: 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
.monthly {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.monthly th,
|
||||
.monthly td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
.monthly .num {
|
||||
text-align: right;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dash {
|
||||
max-width: 100%;
|
||||
}
|
||||
.top-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.monthly th,
|
||||
.monthly td {
|
||||
padding: 0.4rem 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const newName = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
tags.value = await accountApi.tags()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTag() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.createTag({ name })
|
||||
newName.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTag(tag) {
|
||||
const name = (tag.name || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.updateTag(tag.id, { name })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag(tag) {
|
||||
if (!confirm(`'${tag.name}' 태그를 삭제할까요? (내역에서 연결도 해제됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeTag(tag.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tag-admin">
|
||||
<header class="head">
|
||||
<h1>가계부 태그 관리</h1>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</header>
|
||||
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
|
||||
|
||||
<form class="new-tag" @submit.prevent="addTag">
|
||||
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 고정지출)" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="tags.length" class="tag-list">
|
||||
<li v-for="t in tags" :key="t.id" class="tag-row">
|
||||
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" />
|
||||
<IconBtn icon="check" title="저장" @click="saveTag(t)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 태그가 없습니다.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-admin {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.new-tag {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.new-tag input {
|
||||
flex: 1;
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tag-name {
|
||||
flex: 1;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,898 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
|
||||
const entries = ref([])
|
||||
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 검색·필터
|
||||
const filterOpen = ref(false)
|
||||
const filters = reactive({ keyword: '', type: '', category: '', walletId: '', tagId: '' })
|
||||
const hasFilter = computed(() => !!(filters.keyword || filters.type || filters.category || filters.walletId || filters.tagId))
|
||||
function filterParams() {
|
||||
const p = {}
|
||||
if (filters.keyword) p.keyword = filters.keyword.trim()
|
||||
if (filters.type) p.type = filters.type
|
||||
if (filters.category) p.category = filters.category
|
||||
if (filters.walletId) p.walletId = filters.walletId
|
||||
if (filters.tagId) p.tagId = filters.tagId
|
||||
return p
|
||||
}
|
||||
function applyFilters() {
|
||||
load()
|
||||
}
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.type = ''
|
||||
filters.category = ''
|
||||
filters.walletId = ''
|
||||
filters.tagId = ''
|
||||
load()
|
||||
}
|
||||
|
||||
// 추가/수정 모달
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
|
||||
const isRepayment = computed(() => form.type === 'REPAYMENT')
|
||||
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 계좌/카드
|
||||
const wallets = ref([])
|
||||
async function loadWallets() {
|
||||
try {
|
||||
wallets.value = await accountApi.wallets()
|
||||
} catch {
|
||||
wallets.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 분류(카테고리)
|
||||
const categories = ref([])
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
// 필터용 전체 분류명 (중복 제거)
|
||||
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
|
||||
|
||||
// 모달 내 분류 인라인 추가
|
||||
const addingCategory = ref(false)
|
||||
const newCategoryName = ref('')
|
||||
const catSubmitting = ref(false)
|
||||
function startAddCategory() {
|
||||
newCategoryName.value = ''
|
||||
addingCategory.value = true
|
||||
}
|
||||
function cancelAddCategory() {
|
||||
addingCategory.value = false
|
||||
newCategoryName.value = ''
|
||||
}
|
||||
async function confirmAddCategory() {
|
||||
const name = newCategoryName.value.trim()
|
||||
if (!name) return
|
||||
catSubmitting.value = true
|
||||
try {
|
||||
await accountApi.createCategory({ type: form.type, name })
|
||||
await loadCategories()
|
||||
form.category = name
|
||||
cancelAddCategory()
|
||||
} catch (e) {
|
||||
if (e.response?.status === 409) {
|
||||
// 이미 있는 분류면 그대로 선택
|
||||
form.category = name
|
||||
cancelAddCategory()
|
||||
} else {
|
||||
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
catSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 태그 (태그 관리에 등록된 태그 선택)
|
||||
const tagOptions = ref([]) // [{ id, name }]
|
||||
const selectedTagIds = ref([])
|
||||
|
||||
function toggleTag(id) {
|
||||
const i = selectedTagIds.value.indexOf(id)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(id)
|
||||
}
|
||||
|
||||
async function loadTagOptions() {
|
||||
try {
|
||||
tagOptions.value = await accountApi.tags()
|
||||
} catch {
|
||||
tagOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? value : `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
function dotClass(type) {
|
||||
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
||||
}
|
||||
function amountClass(type) {
|
||||
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
||||
}
|
||||
function amountSign(type) {
|
||||
return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const listParams = { year: year.value, month: month.value, ...filterParams() }
|
||||
const sumParams = { year: year.value, month: month.value }
|
||||
const [list, sum] = await Promise.all([accountApi.list(listParams), accountApi.summary(sumParams)])
|
||||
entries.value = list
|
||||
summary.value = sum
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '내역을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (month.value === 1) {
|
||||
month.value = 12
|
||||
year.value -= 1
|
||||
} else {
|
||||
month.value -= 1
|
||||
}
|
||||
load()
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month.value === 12) {
|
||||
month.value = 1
|
||||
year.value += 1
|
||||
} else {
|
||||
month.value += 1
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
|
||||
selectedTagIds.value = []
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(e) {
|
||||
editId.value = e.id
|
||||
Object.assign(form, {
|
||||
entryDate: e.entryDate,
|
||||
type: e.type,
|
||||
category: e.category || '',
|
||||
amount: e.amount,
|
||||
memo: e.memo || '',
|
||||
walletId: e.walletId || '',
|
||||
toWalletId: e.toWalletId || '',
|
||||
})
|
||||
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
||||
const nameToId = {}
|
||||
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
|
||||
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.entryDate) {
|
||||
formError.value = '거래일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
// 상환/납부: 원금=이체, 이자=지출 자동 분리
|
||||
if (isRepayment.value) {
|
||||
if (!form.walletId || !form.toWalletId) {
|
||||
formError.value = '출금 계좌와 대상(대출/카드)을 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (form.walletId === form.toWalletId) {
|
||||
formError.value = '출금/대상 계좌가 같을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
const principal = Number(form.principal) || 0
|
||||
const interest = Number(form.interest) || 0
|
||||
if (principal <= 0 && interest <= 0) {
|
||||
formError.value = '원금 또는 이자를 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await accountApi.repayment({
|
||||
entryDate: form.entryDate,
|
||||
fromWalletId: form.walletId,
|
||||
targetWalletId: form.toWalletId,
|
||||
principal,
|
||||
interest,
|
||||
memo: form.memo || null,
|
||||
})
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (form.amount == null || form.amount < 0) {
|
||||
formError.value = '금액을 올바르게 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (form.type === 'TRANSFER') {
|
||||
if (!form.walletId || !form.toWalletId) {
|
||||
formError.value = '이체는 출금/입금 계좌를 모두 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (form.walletId === form.toWalletId) {
|
||||
formError.value = '출금/입금 계좌가 같을 수 없습니다.'
|
||||
return
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
entryDate: form.entryDate,
|
||||
type: form.type,
|
||||
category: form.category || null,
|
||||
amount: Number(form.amount),
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.update(editId.value, payload)
|
||||
else await accountApi.create(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(e) {
|
||||
if (!confirm('이 내역을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await accountApi.remove(e.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
||||
try {
|
||||
await accountApi.runRecurrings()
|
||||
} catch {
|
||||
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
|
||||
}
|
||||
load()
|
||||
loadTagOptions()
|
||||
loadWallets()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="account">
|
||||
<header class="account-head">
|
||||
<h1>가계부</h1>
|
||||
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
|
||||
</header>
|
||||
|
||||
<div class="month-nav">
|
||||
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
||||
<span class="period">{{ periodLabel }}</span>
|
||||
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
|
||||
<IconBtn
|
||||
icon="filter" title="검색·필터" class="filter-toggle"
|
||||
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filterOpen" class="filter-bar">
|
||||
<input
|
||||
v-model="filters.keyword" type="text" class="f-keyword"
|
||||
placeholder="메모·분류 검색" @keyup.enter="applyFilters"
|
||||
/>
|
||||
<select v-model="filters.type">
|
||||
<option value="">구분 전체</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
<select v-model="filters.category">
|
||||
<option value="">분류 전체</option>
|
||||
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="filters.walletId">
|
||||
<option value="">계좌 전체</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
<select v-model="filters.tagId">
|
||||
<option value="">태그 전체</option>
|
||||
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
|
||||
<IconBtn icon="refresh" title="초기화" @click="resetFilters" />
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card income">
|
||||
<span class="label">수입</span>
|
||||
<span class="value">+{{ won(summary.totalIncome) }}</span>
|
||||
</div>
|
||||
<div class="card expense">
|
||||
<span class="label">지출</span>
|
||||
<span class="value">-{{ won(summary.totalExpense) }}</span>
|
||||
</div>
|
||||
<div class="card balance">
|
||||
<span class="label">잔액</span>
|
||||
<span class="value">{{ won(summary.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<table v-else-if="entries.length" class="entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-date">날짜</th>
|
||||
<th class="col-cat">분류</th>
|
||||
<th>메모</th>
|
||||
<th class="col-amount">금액</th>
|
||||
<th class="col-act"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in entries" :key="e.id">
|
||||
<td class="col-date">{{ formatDate(e.entryDate) }}</td>
|
||||
<td class="col-cat">
|
||||
<span class="type-dot" :class="dotClass(e.type)"></span>
|
||||
<template v-if="e.type === 'TRANSFER'">이체</template>
|
||||
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
|
||||
</td>
|
||||
<td class="memo">
|
||||
<span v-if="e.type === 'TRANSFER'" class="row-wallet">{{ e.walletName }} → {{ e.toWalletName }}</span>
|
||||
<span v-else-if="e.walletName" class="row-wallet">{{ e.walletName }}</span>
|
||||
{{ e.memo }}
|
||||
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
||||
</td>
|
||||
<td class="col-amount" :class="amountClass(e.type)">
|
||||
{{ amountSign(e.type) }}{{ won(e.amount) }}
|
||||
</td>
|
||||
<td class="col-act">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||
|
||||
<form class="entry-form" @submit.prevent="submit">
|
||||
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
|
||||
<select v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type === 'TRANSFER'">입금 계좌
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 -->
|
||||
<template v-if="isRepayment">
|
||||
<label>대상(대출/카드)
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
|
||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
</template>
|
||||
|
||||
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
|
||||
<div v-if="!addingCategory" class="cat-input">
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
|
||||
</div>
|
||||
<div v-else class="cat-input">
|
||||
<input
|
||||
v-model="newCategoryName" type="text"
|
||||
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
|
||||
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
|
||||
/>
|
||||
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
|
||||
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
|
||||
</div>
|
||||
</label>
|
||||
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<div v-if="!isRepayment" class="tag-field">
|
||||
<span class="tag-label">태그</span>
|
||||
<div class="tag-badges">
|
||||
<button
|
||||
v-for="t in tagOptions"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tag-badge"
|
||||
:class="{ active: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>{{ t.name }}</button>
|
||||
<span v-if="!tagOptions.length" class="tag-empty">등록된 태그가 없습니다 (태그 관리에서 추가)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.account-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.period {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.filter-toggle {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
.filter-toggle.on {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.filter-badge {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.filter-bar input,
|
||||
.filter-bar select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-bar .f-keyword {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.card.income .value {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.card.expense .value {
|
||||
color: #c0392b;
|
||||
}
|
||||
.entry-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.entry-table th,
|
||||
.entry-table td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.col-date {
|
||||
width: 60px;
|
||||
}
|
||||
.col-cat {
|
||||
width: 120px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-amount {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-amount.income {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.col-amount.expense {
|
||||
color: #c0392b;
|
||||
}
|
||||
.col-act {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-act button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.type-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.type-dot.income {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.type-dot.expense {
|
||||
background: #c0392b;
|
||||
}
|
||||
.type-dot.transfer {
|
||||
background: #888;
|
||||
}
|
||||
.col-amount.transfer {
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.memo {
|
||||
color: var(--color-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.entry-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.entry-form input,
|
||||
.entry-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cat-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cat-input select,
|
||||
.cat-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cat-add-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-add-btn.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.tag-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tag-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tag-badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-badge.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
background: hsla(160, 100%, 37%, 0.12);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-empty {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.row-tag {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.row-wallet {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 내역 표를 2줄(분류·금액 / 날짜·메모·액션)로 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.account {
|
||||
max-width: 100%;
|
||||
}
|
||||
.summary {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card {
|
||||
padding: 0.6rem 0.7rem;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.entry-table thead {
|
||||
display: none;
|
||||
}
|
||||
.entry-table,
|
||||
.entry-table tbody,
|
||||
.entry-table tr,
|
||||
.entry-table td {
|
||||
display: block;
|
||||
}
|
||||
.entry-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.15rem;
|
||||
padding: 0.6rem 0.1rem;
|
||||
}
|
||||
.entry-table td {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
.entry-table .col-cat {
|
||||
order: 0;
|
||||
flex: 1;
|
||||
white-space: normal;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.entry-table .col-amount {
|
||||
order: 1;
|
||||
width: auto;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.entry-table .col-date {
|
||||
order: 2;
|
||||
width: auto;
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.entry-table .memo {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.entry-table .col-act {
|
||||
order: 4;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter-bar .f-keyword {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,659 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import InvestPortfolio from './InvestPortfolio.vue'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const TABS = [
|
||||
{ key: 'BANK', label: '은행계좌' },
|
||||
{ key: 'CARD', label: '신용/체크카드' },
|
||||
{ key: 'LOAN', label: '대출' },
|
||||
{ key: 'INVEST', label: '투자' },
|
||||
]
|
||||
|
||||
const wallets = ref([])
|
||||
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('BANK')
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
|
||||
const form = reactive({
|
||||
type: 'BANK',
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 드롭다운(계좌별 내역)
|
||||
const expandedId = ref(null)
|
||||
const entriesByWallet = reactive({})
|
||||
const loadingEntries = ref(false)
|
||||
|
||||
const filtered = computed(() => wallets.value.filter((w) => w.type === activeType.value))
|
||||
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
|
||||
|
||||
async function toggleExpand(w) {
|
||||
if (expandedId.value === w.id) {
|
||||
expandedId.value = null
|
||||
return
|
||||
}
|
||||
expandedId.value = w.id
|
||||
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
|
||||
loadingEntries.value = true
|
||||
try {
|
||||
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
|
||||
} catch {
|
||||
entriesByWallet[w.id] = []
|
||||
} finally {
|
||||
loadingEntries.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
|
||||
function effectAmount(e, walletId) {
|
||||
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
|
||||
return e.type === 'INCOME' ? e.amount : -e.amount
|
||||
}
|
||||
function entryDesc(e, w) {
|
||||
const m = e.memo ? ` · ${e.memo}` : ''
|
||||
if (e.type === 'TRANSFER') {
|
||||
if (e.toWalletId === w.id) {
|
||||
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
|
||||
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
|
||||
return `${inLabel} ← ${e.walletName || '계좌'}${m}`
|
||||
}
|
||||
return `이체 → ${e.toWalletName || '계좌'}${m}`
|
||||
}
|
||||
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
|
||||
return `${e.category || '지출'}${m}`
|
||||
}
|
||||
function entryDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime())
|
||||
? v
|
||||
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function issuerLabel(t) {
|
||||
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
|
||||
}
|
||||
function openingLabel(t) {
|
||||
return t === 'BANK' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '초기 투입원금' : '대출 잔액(원금)'
|
||||
}
|
||||
// 투자 수익률(%) — 투입원금 대비 평가손익
|
||||
function returnPct(w) {
|
||||
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
|
||||
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [ws, nw] = await Promise.all([accountApi.wallets(), accountApi.netWorth()])
|
||||
wallets.value = ws
|
||||
networth.value = nw
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
type: activeType.value,
|
||||
name: '',
|
||||
issuer: '',
|
||||
accountNumber: '',
|
||||
cardType: 'CREDIT',
|
||||
openingBalance: 0,
|
||||
openingDate: '',
|
||||
currentValue: null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(w) {
|
||||
editId.value = w.id
|
||||
Object.assign(form, {
|
||||
type: w.type,
|
||||
name: w.name,
|
||||
issuer: w.issuer || '',
|
||||
accountNumber: w.accountNumber || '',
|
||||
cardType: w.cardType || 'CREDIT',
|
||||
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
|
||||
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
|
||||
openingDate: w.openingDate || '',
|
||||
currentValue: w.currentValue ?? null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.name.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const magnitude = Number(form.openingBalance) || 0
|
||||
const payload = {
|
||||
type: form.type,
|
||||
name: form.name.trim(),
|
||||
issuer: form.issuer || null,
|
||||
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
|
||||
cardType: form.type === 'CARD' ? form.cardType : null,
|
||||
// 부채는 음수로 저장 (갚을 금액)
|
||||
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
|
||||
openingDate: form.openingDate || null,
|
||||
currentValue:
|
||||
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
|
||||
? Number(form.currentValue)
|
||||
: null,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateWallet(editId.value, payload)
|
||||
else await accountApi.createWallet(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(w) {
|
||||
if (!confirm(`'${w.name}' 을(를) 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeWallet(w.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeLabel(v) {
|
||||
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wallet">
|
||||
<header class="head">
|
||||
<h1>계좌 관리</h1>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</header>
|
||||
|
||||
<!-- 순자산 요약 -->
|
||||
<div class="networth">
|
||||
<div class="nw-card">
|
||||
<span class="label">총자산</span>
|
||||
<span class="value asset">{{ won(networth.totalAssets) }}</span>
|
||||
</div>
|
||||
<div class="nw-card">
|
||||
<span class="label">총부채</span>
|
||||
<span class="value debt">{{ won(networth.totalLiabilities) }}</span>
|
||||
</div>
|
||||
<div class="nw-card">
|
||||
<span class="label">순자산</span>
|
||||
<span class="value">{{ won(networth.netWorth) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
:class="{ active: activeType === t.key }"
|
||||
@click="activeType = t.key"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<IconBtn icon="plus" title="추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="filtered.length" class="wallet-list">
|
||||
<li v-for="w in filtered" :key="w.id" class="wallet-item">
|
||||
<div class="wallet-row" @click="toggleExpand(w)">
|
||||
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="name">{{ w.name }}</span>
|
||||
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ w.issuer }}
|
||||
<template v-if="w.type === 'BANK' && w.accountNumber"> · {{ w.accountNumber }}</template>
|
||||
<template v-if="w.type === 'INVEST'"> · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="balance-wrap">
|
||||
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
|
||||
<div
|
||||
v-if="w.type === 'INVEST' && w.valuationGain != null"
|
||||
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
|
||||
>
|
||||
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" @click.stop>
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운: 투자는 포트폴리오, 그 외는 계좌별 내역 -->
|
||||
<div v-if="expandedId === w.id" class="entry-drop">
|
||||
<InvestPortfolio v-if="w.type === 'INVEST'" :wallet-id="w.id" @changed="load" />
|
||||
<template v-else>
|
||||
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
|
||||
<ul v-else-if="(entriesByWallet[w.id] || []).length" class="drop-list">
|
||||
<li v-for="e in entriesByWallet[w.id]" :key="e.id" class="drop-row">
|
||||
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
|
||||
<span class="d-desc">{{ entryDesc(e, w) }}</span>
|
||||
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
|
||||
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 항목이 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="wallet-form" @submit.prevent="submit">
|
||||
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
|
||||
<label>{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
<label v-if="form.type === 'CARD'">카드 종류
|
||||
<select v-model="form.cardType" :disabled="submitting">
|
||||
<option value="CREDIT">신용카드</option>
|
||||
<option value="CHECK">체크카드</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
|
||||
<p v-if="form.type === 'INVEST'" class="form-hint">
|
||||
증권계좌 입금은 은행→투자 ‘이체’로 기록하세요. 계좌를 펼치면 <b>종목·매수/매도·현재가</b>를 관리할 수 있고, 평가금액·손익은 자동 계산됩니다.
|
||||
</p>
|
||||
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wallet {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.networth {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.nw-card {
|
||||
flex: 1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.nw-card .label {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nw-card .value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.nw-card .value.asset {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.nw-card .value.debt {
|
||||
color: #c0392b;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.wallet-list {
|
||||
list-style: none;
|
||||
}
|
||||
.wallet-item {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.wallet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wallet-row:hover {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.chevron {
|
||||
width: 1rem;
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.entry-drop {
|
||||
padding: 0.25rem 0 0.75rem 1.6rem;
|
||||
}
|
||||
.drop-msg {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.drop-list {
|
||||
list-style: none;
|
||||
}
|
||||
.drop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.d-date {
|
||||
width: 3rem;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d-amount {
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.d-amount.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.d-amount.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.balance-wrap {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.gain {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gain.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.gain.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.65;
|
||||
margin: -0.2rem 0 0.1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wallet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.wallet-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.wallet-form input,
|
||||
.wallet-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wallet {
|
||||
max-width: 100%;
|
||||
}
|
||||
.networth {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nw-card {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.nw-card .value {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 0.55rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
/* 한 줄 유지: 이름(축소) · 잔액 · 아이콘 */
|
||||
.wallet-row {
|
||||
column-gap: 0.4rem;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 이름은 한 줄(말줄임), sub(예수금·주식 등)는 자연 줄바꿈 — 손익과 겹치지 않게 */
|
||||
.line1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub {
|
||||
word-break: break-all;
|
||||
}
|
||||
.wallet-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.balance {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.balance-wrap {
|
||||
flex-shrink: 0;
|
||||
padding-left: 0.3rem;
|
||||
}
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,544 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
|
||||
const statuses = ref([])
|
||||
const budgetsRaw = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
// 분류(지출) 드롭다운
|
||||
const categories = ref([])
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch {
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function daysInMonth(y, m) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
function daysInYear(y) {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
|
||||
}
|
||||
function baseDays(unit) {
|
||||
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
|
||||
}
|
||||
|
||||
// 고정 모드 자동 환산 미리보기 (현재 월/년 실제 일수 기준)
|
||||
const fixedPreview = computed(() => {
|
||||
if (!form.fixed) return null
|
||||
const amount = Number(form.baseAmount) || 0
|
||||
const daily = amount / baseDays(form.baseUnit)
|
||||
return {
|
||||
weekly: Math.round(daily * 7),
|
||||
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
|
||||
yearly: Math.round(daily * daysInYear(year.value)),
|
||||
}
|
||||
})
|
||||
|
||||
function ratio(s) {
|
||||
if (!s.monthlyBudget) return 0
|
||||
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
|
||||
}
|
||||
function barClass(s) {
|
||||
const r = s.monthlyBudget ? s.spent / s.monthlyBudget : 0
|
||||
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = { year: year.value, month: month.value }
|
||||
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
|
||||
statuses.value = st
|
||||
budgetsRaw.value = raw
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (month.value === 1) {
|
||||
month.value = 12
|
||||
year.value -= 1
|
||||
} else month.value -= 1
|
||||
load()
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month.value === 12) {
|
||||
month.value = 1
|
||||
year.value += 1
|
||||
} else month.value += 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
category: '',
|
||||
fixed: false,
|
||||
baseUnit: 'DAY',
|
||||
baseAmount: null,
|
||||
daily: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(s) {
|
||||
const b = budgetsRaw.value.find((x) => x.id === s.id)
|
||||
if (!b) return
|
||||
editId.value = b.id
|
||||
Object.assign(form, {
|
||||
category: b.category,
|
||||
fixed: b.fixed,
|
||||
baseUnit: b.baseUnit || 'DAY',
|
||||
baseAmount: b.baseAmount,
|
||||
daily: b.daily,
|
||||
weekly: b.weekly,
|
||||
monthly: b.monthly,
|
||||
yearly: b.yearly,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.category.trim()) {
|
||||
formError.value = '카테고리를 입력하세요.'
|
||||
return
|
||||
}
|
||||
let payload
|
||||
if (form.fixed) {
|
||||
if (!form.baseAmount || form.baseAmount <= 0) {
|
||||
formError.value = '기준 금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
|
||||
} else {
|
||||
payload = {
|
||||
category: form.category.trim(),
|
||||
fixed: false,
|
||||
daily: form.daily != null ? Number(form.daily) : null,
|
||||
weekly: form.weekly != null ? Number(form.weekly) : null,
|
||||
monthly: form.monthly != null ? Number(form.monthly) : null,
|
||||
yearly: form.yearly != null ? Number(form.yearly) : null,
|
||||
}
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editId.value) await accountApi.updateBudget(editId.value, payload)
|
||||
else await accountApi.createBudget(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s) {
|
||||
if (!confirm(`'${s.category}' 예산을 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeBudget(s.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="budget">
|
||||
<header class="head">
|
||||
<h1>예산 설정</h1>
|
||||
<div class="head-actions">
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="month-nav">
|
||||
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
|
||||
<span class="period">{{ periodLabel }}</span>
|
||||
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="statuses.length" class="status-list">
|
||||
<li v-for="s in statuses" :key="s.id" class="status-row">
|
||||
<div class="srow-top">
|
||||
<span class="cat">{{ s.category }}<span v-if="s.fixed" class="fixed-badge">고정</span></span>
|
||||
<span class="amounts">
|
||||
<span :class="s.remaining < 0 ? 'neg' : ''">{{ won(s.spent) }}</span>
|
||||
/ {{ won(s.monthlyBudget) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" :class="barClass(s)" :style="{ width: Math.min(ratio(s), 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="srow-bottom">
|
||||
<span class="ratio" :class="barClass(s)">{{ ratio(s) }}%</span>
|
||||
<span class="remain">
|
||||
{{ s.remaining >= 0 ? `잔여 ${won(s.remaining)}` : `초과 ${won(-s.remaining)}` }}
|
||||
</span>
|
||||
<span class="acts">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(s)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(s)" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가"로 시작하세요.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="budget-form" @submit.prevent="submit">
|
||||
<label>카테고리
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mode">
|
||||
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
|
||||
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
|
||||
</div>
|
||||
|
||||
<!-- 고정 -->
|
||||
<template v-if="form.fixed">
|
||||
<div class="fixed-base">
|
||||
<select v-model="form.baseUnit" :disabled="submitting">
|
||||
<option value="DAY">일</option>
|
||||
<option value="WEEK">주</option>
|
||||
<option value="MONTH">월</option>
|
||||
</select>
|
||||
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
|
||||
</div>
|
||||
<p v-if="fixedPreview" class="preview">
|
||||
환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
|
||||
<span class="note">(이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 비고정 -->
|
||||
<template v-else>
|
||||
<label>일 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>주 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>년 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.budget {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.period {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.status-list {
|
||||
list-style: none;
|
||||
}
|
||||
.status-row {
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.srow-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.cat {
|
||||
font-weight: 600;
|
||||
}
|
||||
.fixed-badge {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.amounts {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.amounts .neg {
|
||||
color: #c0392b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bar {
|
||||
height: 8px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.bar-fill.ok {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.bar-fill.warn {
|
||||
background: #e67e22;
|
||||
}
|
||||
.bar-fill.over {
|
||||
background: #c0392b;
|
||||
}
|
||||
.srow-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ratio.ok {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.ratio.warn {
|
||||
color: #e67e22;
|
||||
}
|
||||
.ratio.over {
|
||||
color: #c0392b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.remain {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.acts {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.acts button {
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.budget-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.budget-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.budget-form input,
|
||||
.budget-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.radio {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.3rem !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fixed-base {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fixed-base select {
|
||||
width: 5rem;
|
||||
}
|
||||
.fixed-base input {
|
||||
flex: 1;
|
||||
}
|
||||
.preview {
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const activeType = ref('EXPENSE') // EXPENSE / INCOME
|
||||
const newName = ref('')
|
||||
|
||||
const filtered = computed(() => categories.value.filter((c) => c.type === activeType.value))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
categories.value = await accountApi.categories()
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.createCategory({ type: activeType.value, name })
|
||||
newName.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(c) {
|
||||
const name = (c.name || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await accountApi.updateCategory(c.id, { type: c.type, name })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCategory(c) {
|
||||
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeCategory(c.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function importExisting() {
|
||||
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return
|
||||
try {
|
||||
categories.value = await accountApi.importCategories()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '불러오기에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="cat">
|
||||
<header class="head">
|
||||
<h1>분류 관리</h1>
|
||||
<div class="head-actions">
|
||||
<button type="button" class="text-btn" @click="importExisting">기존 분류 불러오기</button>
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. 분류 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
|
||||
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
|
||||
</div>
|
||||
|
||||
<form class="new-cat" @submit.prevent="addCategory">
|
||||
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
|
||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="filtered.length" class="cat-list">
|
||||
<li v-for="c in filtered" :key="c.id" class="cat-row">
|
||||
<input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
|
||||
<IconBtn icon="check" title="저장" @click="saveCategory(c)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">
|
||||
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cat {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-bottom-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-cat {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.new-cat input {
|
||||
flex: 1;
|
||||
}
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
}
|
||||
.cat-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.cat-name {
|
||||
flex: 1;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const props = defineProps({ walletId: { type: [Number, String], required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const holdings = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 종목 추가/수정 모달
|
||||
const holdingModal = ref(false)
|
||||
const holdingEditId = ref(null)
|
||||
const hForm = reactive({ name: '', ticker: '', currentPrice: null })
|
||||
const hSubmitting = ref(false)
|
||||
const hError = ref(null)
|
||||
|
||||
// 매매 모달
|
||||
const tradeModal = ref(false)
|
||||
const tradeHolding = ref(null)
|
||||
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, fee: null })
|
||||
const tSubmitting = ref(false)
|
||||
const tError = ref(null)
|
||||
|
||||
// 매매이력 드롭다운
|
||||
const openTradesId = ref(null)
|
||||
const tradesByHolding = reactive({})
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function tDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? v : `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
holdings.value = await accountApi.investHoldings(props.walletId)
|
||||
} catch {
|
||||
holdings.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 종목 ===== */
|
||||
function openAddHolding() {
|
||||
holdingEditId.value = null
|
||||
Object.assign(hForm, { name: '', ticker: '', currentPrice: null })
|
||||
hError.value = null
|
||||
holdingModal.value = true
|
||||
}
|
||||
function openEditHolding(h) {
|
||||
holdingEditId.value = h.id
|
||||
Object.assign(hForm, { name: h.name, ticker: h.ticker || '', currentPrice: h.currentPrice ?? null })
|
||||
hError.value = null
|
||||
holdingModal.value = true
|
||||
}
|
||||
async function submitHolding() {
|
||||
hError.value = null
|
||||
if (!hForm.name.trim()) {
|
||||
hError.value = '종목명을 입력하세요.'
|
||||
return
|
||||
}
|
||||
hSubmitting.value = true
|
||||
const payload = {
|
||||
walletId: Number(props.walletId),
|
||||
name: hForm.name.trim(),
|
||||
ticker: hForm.ticker || null,
|
||||
currentPrice: hForm.currentPrice !== '' && hForm.currentPrice != null ? Number(hForm.currentPrice) : null,
|
||||
}
|
||||
try {
|
||||
if (holdingEditId.value) await accountApi.updateHolding(holdingEditId.value, payload)
|
||||
else await accountApi.createHolding(payload)
|
||||
holdingModal.value = false
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
hError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
hSubmitting.value = false
|
||||
}
|
||||
}
|
||||
async function removeHolding(h) {
|
||||
if (!confirm(`'${h.name}' 종목과 매매이력을 모두 삭제할까요?`)) return
|
||||
try {
|
||||
await accountApi.removeHolding(h.id)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 매매 ===== */
|
||||
function openTrade(h, type) {
|
||||
tradeHolding.value = h
|
||||
Object.assign(tForm, { tradeType: type, tradeDate: todayStr(), quantity: null, price: h.currentPrice ?? null, fee: null })
|
||||
tError.value = null
|
||||
tradeModal.value = true
|
||||
}
|
||||
async function submitTrade() {
|
||||
tError.value = null
|
||||
if (!tForm.quantity || tForm.quantity <= 0) {
|
||||
tError.value = '수량을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (tForm.price == null || tForm.price < 0) {
|
||||
tError.value = '단가를 입력하세요.'
|
||||
return
|
||||
}
|
||||
tSubmitting.value = true
|
||||
const payload = {
|
||||
tradeType: tForm.tradeType,
|
||||
tradeDate: tForm.tradeDate,
|
||||
quantity: Number(tForm.quantity),
|
||||
price: Number(tForm.price),
|
||||
fee: tForm.fee ? Number(tForm.fee) : 0,
|
||||
}
|
||||
try {
|
||||
await accountApi.addTrade(tradeHolding.value.id, payload)
|
||||
tradeModal.value = false
|
||||
if (openTradesId.value === tradeHolding.value.id) await loadTrades(tradeHolding.value.id)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
tError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
tSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrades(holdingId) {
|
||||
try {
|
||||
tradesByHolding[holdingId] = await accountApi.holdingTrades(holdingId)
|
||||
} catch {
|
||||
tradesByHolding[holdingId] = []
|
||||
}
|
||||
}
|
||||
async function toggleTrades(h) {
|
||||
if (openTradesId.value === h.id) {
|
||||
openTradesId.value = null
|
||||
return
|
||||
}
|
||||
openTradesId.value = h.id
|
||||
await loadTrades(h.id)
|
||||
}
|
||||
async function removeTrade(t, holdingId) {
|
||||
if (!confirm('이 매매 내역을 삭제할까요?')) return
|
||||
try {
|
||||
await accountApi.removeTrade(t.id)
|
||||
await loadTrades(holdingId)
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="portfolio">
|
||||
<div class="pf-head">
|
||||
<span class="pf-title">보유 종목</span>
|
||||
<IconBtn icon="plus" title="종목 추가" variant="primary" size="sm" @click="openAddHolding" />
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="pf-msg">불러오는 중...</p>
|
||||
<p v-else-if="!holdings.length" class="pf-msg">보유 종목이 없습니다. 종목을 추가하고 매수를 기록하세요.</p>
|
||||
|
||||
<ul v-else class="hold-list">
|
||||
<li v-for="h in holdings" :key="h.id" class="hold-item">
|
||||
<div class="hold-row">
|
||||
<div class="h-info" @click="toggleTrades(h)">
|
||||
<span class="chev">{{ openTradesId === h.id ? '▾' : '▸' }}</span>
|
||||
<div>
|
||||
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
|
||||
<div class="h-sub">
|
||||
{{ won(h.quantity) }}주 · 평단 {{ won(h.avgPrice) }}
|
||||
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
|
||||
<span v-else class="noprice"> · 현재가 미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-val">
|
||||
<div class="h-eval">{{ won(h.evalValue) }}</div>
|
||||
<div class="h-gain" :class="h.evalGain < 0 ? 'neg' : 'pos'">
|
||||
{{ h.evalGain >= 0 ? '+' : '' }}{{ won(h.evalGain) }}<span v-if="h.returnPct != null"> ({{ h.returnPct }}%)</span>
|
||||
</div>
|
||||
<div v-if="h.realizedPL" class="h-realized">실현 {{ h.realizedPL >= 0 ? '+' : '' }}{{ won(h.realizedPL) }}</div>
|
||||
</div>
|
||||
<div class="h-actions">
|
||||
<IconBtn icon="plus" title="매수" variant="buy" size="sm" @click="openTrade(h, 'BUY')" />
|
||||
<IconBtn icon="minus" title="매도" variant="sell" size="sm" :disabled="h.quantity <= 0" @click="openTrade(h, 'SELL')" />
|
||||
<IconBtn icon="edit" title="현재가 수정" size="sm" @click="openEditHolding(h)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeHolding(h)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 매매이력 -->
|
||||
<div v-if="openTradesId === h.id" class="trade-drop">
|
||||
<ul v-if="(tradesByHolding[h.id] || []).length" class="trade-list">
|
||||
<li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row">
|
||||
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
|
||||
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
|
||||
<span class="t-qty">{{ won(t.quantity) }}주 × {{ won(t.price) }}</span>
|
||||
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
|
||||
<span class="t-amt">{{ won(t.amount) }}</span>
|
||||
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 종목 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="holdingModal = false">×</button>
|
||||
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitHolding">
|
||||
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
|
||||
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="(선택) 예: 005930" :disabled="hSubmitting" /></label>
|
||||
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(수동 갱신)" :disabled="hSubmitting" /></label>
|
||||
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
|
||||
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 매매 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="tradeModal = false">×</button>
|
||||
<h2>{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}</h2>
|
||||
<form class="pf-form" @submit.prevent="submitTrade">
|
||||
<label>구분
|
||||
<select v-model="tForm.tradeType" :disabled="tSubmitting">
|
||||
<option value="BUY">매수</option>
|
||||
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
|
||||
<label>수량(주)<input v-model.number="tForm.quantity" type="number" min="1" :disabled="tSubmitting" /></label>
|
||||
<label>단가(원)<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" /></label>
|
||||
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
|
||||
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ won(tradeHolding?.quantity) }}주 · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
|
||||
<p v-if="tError" class="pf-msg err">{{ tError }}</p>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
|
||||
<IconBtn icon="save" title="등록" variant="primary" type="submit" :disabled="tSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portfolio {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
.pf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.pf-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.pf-add {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pf-msg {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.pf-msg.sm {
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.pf-msg.err {
|
||||
color: #c0392b;
|
||||
opacity: 1;
|
||||
}
|
||||
.hold-list {
|
||||
list-style: none;
|
||||
}
|
||||
.hold-item {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.hold-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.h-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chev {
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.h-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.h-ticker {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.h-sub {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.noprice {
|
||||
color: #e67e22;
|
||||
}
|
||||
.h-val {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-eval {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.h-gain {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.h-gain.pos {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.h-gain.neg {
|
||||
color: #c0392b;
|
||||
}
|
||||
.h-realized {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.h-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.h-actions button {
|
||||
padding: 0.18rem 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.h-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.h-actions .buy {
|
||||
border-color: #2e7d32;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.h-actions .sell {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.h-actions .danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.trade-drop {
|
||||
padding: 0.1rem 0 0.5rem 1.4rem;
|
||||
}
|
||||
.trade-list {
|
||||
list-style: none;
|
||||
}
|
||||
.trade-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.t-date {
|
||||
opacity: 0.7;
|
||||
width: 3rem;
|
||||
}
|
||||
.t-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
.t-type.buy {
|
||||
color: #2e7d32;
|
||||
}
|
||||
.t-type.sell {
|
||||
color: #c0392b;
|
||||
}
|
||||
.t-qty {
|
||||
flex: 1;
|
||||
}
|
||||
.t-fee {
|
||||
opacity: 0.6;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.t-amt {
|
||||
font-weight: 600;
|
||||
}
|
||||
.t-del {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #c0392b;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 모달 (계좌 관리와 동일 톤) */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pf-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.pf-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pf-form input,
|
||||
.pf-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pf-hint {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pf-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.pf-buttons button {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pf-buttons .primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.hold-row {
|
||||
column-gap: 0.4rem;
|
||||
}
|
||||
.h-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.trade-drop {
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
.trade-row {
|
||||
flex-wrap: wrap;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
.t-date,
|
||||
.t-type,
|
||||
.t-qty,
|
||||
.t-fee,
|
||||
.t-amt {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-date {
|
||||
order: 0;
|
||||
width: auto;
|
||||
}
|
||||
.t-type {
|
||||
order: 1;
|
||||
}
|
||||
.t-amt {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
.t-del {
|
||||
order: 3;
|
||||
}
|
||||
.t-qty {
|
||||
order: 4;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.t-fee {
|
||||
order: 5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const recurrings = ref([])
|
||||
const wallets = ref([])
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editId = ref(null)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
type: 'EXPENSE',
|
||||
amount: null,
|
||||
category: '',
|
||||
memo: '',
|
||||
walletId: '',
|
||||
toWalletId: '',
|
||||
frequency: 'MONTHLY',
|
||||
dayOfMonth: 1,
|
||||
dayOfWeek: 1,
|
||||
monthOfYear: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
active: true,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
|
||||
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
|
||||
const categoryOptions = computed(() => {
|
||||
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
|
||||
if (form.category && !names.includes(form.category)) names.unshift(form.category)
|
||||
return names
|
||||
})
|
||||
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
function typeLabel(t) {
|
||||
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
|
||||
}
|
||||
function freqLabel(r) {
|
||||
if (r.frequency === 'DAILY') return '매일'
|
||||
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
|
||||
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}일`
|
||||
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
|
||||
}
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [recs, ws, cats] = await Promise.all([
|
||||
accountApi.recurrings(),
|
||||
accountApi.wallets(),
|
||||
accountApi.categories(),
|
||||
])
|
||||
recurrings.value = recs
|
||||
wallets.value = ws
|
||||
categories.value = cats
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
try {
|
||||
const res = await accountApi.runRecurrings()
|
||||
alert(`${res.generated}건의 정기 거래가 반영되었습니다.`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '반영에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = null
|
||||
Object.assign(form, {
|
||||
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
|
||||
walletId: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
|
||||
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(r) {
|
||||
editId.value = r.id
|
||||
Object.assign(form, {
|
||||
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
|
||||
walletId: r.walletId || '', toWalletId: r.toWalletId || '', frequency: r.frequency,
|
||||
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
|
||||
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
|
||||
})
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
if (!form.title.trim()) {
|
||||
formError.value = '이름을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
formError.value = '금액을 입력하세요.'
|
||||
return
|
||||
}
|
||||
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
|
||||
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
|
||||
return
|
||||
}
|
||||
if (!form.startDate) {
|
||||
formError.value = '시작일을 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
type: form.type,
|
||||
amount: Number(form.amount),
|
||||
category: form.type === 'TRANSFER' ? null : form.category || null,
|
||||
memo: form.memo || null,
|
||||
walletId: form.walletId || null,
|
||||
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
|
||||
frequency: form.frequency,
|
||||
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
|
||||
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
|
||||
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
active: form.active,
|
||||
}
|
||||
try {
|
||||
if (editId.value) await accountApi.updateRecurring(editId.value, payload)
|
||||
else await accountApi.createRecurring(payload)
|
||||
formOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r) {
|
||||
if (!confirm(`'${r.title}' 정기 거래를 삭제할까요? (이미 생성된 내역은 유지됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeRecurring(r.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="recur">
|
||||
<header class="head">
|
||||
<h1>정기 거래</h1>
|
||||
<div class="head-actions">
|
||||
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
|
||||
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
|
||||
<IconBtn icon="plus" title="정기 거래 추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">등록한 주기에 맞춰 가계부 진입 시 자동으로 내역이 생성됩니다. (중복 없이)</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<ul v-else-if="recurrings.length" class="recur-list">
|
||||
<li v-for="r in recurrings" :key="r.id" class="recur-row" :class="{ inactive: !r.active }">
|
||||
<div class="info">
|
||||
<div class="line1">
|
||||
<span class="title">{{ r.title }}</span>
|
||||
<span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span>
|
||||
<span v-if="!r.active" class="off">중지</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ freqLabel(r) }} · {{ won(r.amount) }}
|
||||
<template v-if="r.category"> · {{ r.category }}</template>
|
||||
<template v-if="r.nextDate"> · 다음 {{ r.nextDate }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(r)" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="!loading" class="msg">등록된 정기 거래가 없습니다.</p>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button class="close" type="button" @click="formOpen = false">×</button>
|
||||
<h2>정기 거래 {{ editId ? '수정' : '추가' }}</h2>
|
||||
|
||||
<form class="recur-form" @submit.prevent="submit">
|
||||
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
|
||||
<label>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
<option value="EXPENSE">지출</option>
|
||||
<option value="INCOME">수입</option>
|
||||
<option value="TRANSFER">이체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
|
||||
<label>{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}
|
||||
<select v-model="form.walletId" :disabled="submitting">
|
||||
<option value="">(선택 안 함)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type === 'TRANSFER'">입금 계좌
|
||||
<select v-model="form.toWalletId" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.type !== 'TRANSFER'">분류
|
||||
<select v-model="form.category" :disabled="submitting">
|
||||
<option value="">(선택)</option>
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<label>주기
|
||||
<select v-model="form.frequency" :disabled="submitting">
|
||||
<option value="DAILY">매일</option>
|
||||
<option value="WEEKLY">매주</option>
|
||||
<option value="MONTHLY">매월</option>
|
||||
<option value="YEARLY">매년</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'WEEKLY'">요일
|
||||
<select v-model.number="form.dayOfWeek" :disabled="submitting">
|
||||
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.frequency === 'YEARLY'">월
|
||||
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
|
||||
</label>
|
||||
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">일(날짜)
|
||||
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
|
||||
</label>
|
||||
|
||||
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
|
||||
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
|
||||
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
|
||||
|
||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recur {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
button.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.recur-list {
|
||||
list-style: none;
|
||||
}
|
||||
.recur-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.recur-row.inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.type-badge {
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.type-badge.income {
|
||||
color: #2e7d32;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
.type-badge.expense {
|
||||
color: #c0392b;
|
||||
border-color: #c0392b;
|
||||
}
|
||||
.off {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.83rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.6rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.recur-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.recur-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.recur-form label.row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.recur-form input,
|
||||
.recur-form select {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.recur-form label.row input {
|
||||
padding: 0;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi } from '@/api/adminApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const newCategoryName = ref('')
|
||||
const newTag = reactive({}) // { [categoryId]: '입력값' }
|
||||
const boardCategoryId = ref('') // 게시판에서 사용할 카테고리 ('' = 제한 없음)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
categories.value = await adminApi.categories()
|
||||
categories.value.forEach((c) => {
|
||||
if (newTag[c.id] === undefined) newTag[c.id] = ''
|
||||
})
|
||||
const setting = await adminApi.getBoardSetting()
|
||||
boardCategoryId.value = setting.tagCategoryId ?? ''
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBoardSetting() {
|
||||
try {
|
||||
const id = boardCategoryId.value === '' ? null : Number(boardCategoryId.value)
|
||||
await adminApi.setBoardSetting(id)
|
||||
alert('게시판 사용 카테고리를 저장했습니다.')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '저장 실패')
|
||||
}
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = newCategoryName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await adminApi.createCategory({ name, sortOrder: categories.value.length })
|
||||
newCategoryName.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '카테고리 추가 실패')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(cat) {
|
||||
const name = (cat.name || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await adminApi.updateCategory(cat.id, { name, sortOrder: cat.sortOrder })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '카테고리 수정 실패')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCategory(cat) {
|
||||
if (!confirm(`'${cat.name}' 카테고리와 소속 태그를 모두 삭제할까요?`)) return
|
||||
try {
|
||||
await adminApi.removeCategory(cat.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제 실패')
|
||||
}
|
||||
}
|
||||
|
||||
async function addTag(cat) {
|
||||
const name = (newTag[cat.id] || '').trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await adminApi.createTag({ name, categoryId: cat.id })
|
||||
newTag[cat.id] = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '태그 추가 실패')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag(tag) {
|
||||
if (!confirm(`'${tag.name}' 태그를 삭제할까요?`)) return
|
||||
try {
|
||||
await adminApi.removeTag(tag.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제 실패')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tag-admin">
|
||||
<h1>태그 관리</h1>
|
||||
|
||||
<div class="board-setting">
|
||||
<label>게시판 사용 카테고리</label>
|
||||
<select v-model="boardCategoryId">
|
||||
<option value="">전체 허용 (제한 없음)</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
<IconBtn icon="check" title="저장" variant="primary" @click="saveBoardSetting" />
|
||||
<span class="hint">선택 시 글쓰기에서 해당 카테고리의 태그만 사용할 수 있습니다.</span>
|
||||
</div>
|
||||
|
||||
<form class="new-category" @submit.prevent="addCategory">
|
||||
<input v-model="newCategoryName" type="text" placeholder="새 카테고리 이름 (예: 게시판, 게시판2)" />
|
||||
<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>
|
||||
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-card">
|
||||
<div class="cat-head">
|
||||
<input v-model="cat.name" class="cat-name" @keyup.enter="saveCategory(cat)" />
|
||||
<IconBtn icon="check" title="이름 저장" @click="saveCategory(cat)" />
|
||||
<IconBtn icon="trash" title="카테고리 삭제" variant="danger" @click="removeCategory(cat)" />
|
||||
</div>
|
||||
|
||||
<div class="tag-list">
|
||||
<span v-for="t in cat.tags" :key="t.id" class="tag-chip">
|
||||
{{ t.name }}
|
||||
<button type="button" class="tag-x" @click="removeTag(t)">×</button>
|
||||
</span>
|
||||
<span v-if="!cat.tags.length" class="empty">태그 없음</span>
|
||||
</div>
|
||||
|
||||
<form class="new-tag" @submit.prevent="addTag(cat)">
|
||||
<input v-model="newTag[cat.id]" type="text" placeholder="태그 추가" />
|
||||
<IconBtn icon="plus" title="태그 추가" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p v-if="!loading && !categories.length" class="msg">카테고리를 추가해 태그 관리를 시작하세요.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-admin {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 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;
|
||||
}
|
||||
.board-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.board-setting label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.board-setting select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.board-setting .hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.65;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.new-category {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.new-category input {
|
||||
flex: 1;
|
||||
}
|
||||
.category-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.cat-head {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.cat-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 1.8rem;
|
||||
}
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tag-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #c0392b;
|
||||
cursor: pointer;
|
||||
padding: 0 0.1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.new-tag {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.new-tag input {
|
||||
flex: 1;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatRelative } from '@/utils/datetime'
|
||||
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
|
||||
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const postId = route.params.id
|
||||
const post = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const commentText = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
|
||||
// 비관리자에게 제한된 글 (본문/댓글 숨김)
|
||||
const restricted = computed(() => post.value?.blocked && !isAdmin.value)
|
||||
|
||||
const canModifyPost = computed(() => {
|
||||
if (!post.value || !auth.user) return false
|
||||
return auth.user.id === post.value.authorId || isAdmin.value
|
||||
})
|
||||
|
||||
function canModifyComment(c) {
|
||||
if (!auth.user) return false
|
||||
return auth.user.id === c.authorId || isAdmin.value
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
post.value = await boardApi.get(postId)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '게시글을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePost() {
|
||||
if (!confirm('이 게시글을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await boardApi.remove(postId)
|
||||
router.replace('/board')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function blockPost() {
|
||||
const reason = prompt('열람 제한 사유 (선택):', '')
|
||||
if (reason === null) return
|
||||
try {
|
||||
await boardApi.block(postId, reason)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function unblockPost() {
|
||||
try {
|
||||
await boardApi.unblock(postId)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment() {
|
||||
const content = commentText.value.trim()
|
||||
if (!content) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const c = await boardApi.addComment(postId, content)
|
||||
post.value.comments.push(c)
|
||||
commentText.value = ''
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '댓글 등록에 실패했습니다.')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeComment(c) {
|
||||
if (!confirm('댓글을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await boardApi.removeComment(c.id)
|
||||
post.value.comments = post.value.comments.filter((x) => x.id !== c.id)
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="detail">
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<article v-if="post">
|
||||
<header class="post-head">
|
||||
<h1>{{ post.title }}</h1>
|
||||
<div class="meta">
|
||||
<span>{{ post.authorName }}</span>
|
||||
<span>· {{ formatRelative(post.createdAt) }}</span>
|
||||
<span>· 조회 {{ post.viewCount }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 관리자에게: 제한 상태 배너 -->
|
||||
<div v-if="post.blocked && isAdmin" class="admin-banner">
|
||||
🚫 열람 제한된 글입니다 (관리자만 열람)
|
||||
<span v-if="post.blockReason">— 사유: {{ post.blockReason }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 비관리자 제한 메시지 -->
|
||||
<div v-if="restricted" class="restricted">
|
||||
🚫 관리자에 의해 열람이 제한된 게시글입니다.
|
||||
</div>
|
||||
|
||||
<MarkdownViewer v-else :content="post.content" class="content" />
|
||||
|
||||
<!-- 태그 (본문 아래) -->
|
||||
<div v-if="!restricted && post.tags?.length" class="tags">
|
||||
<span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<IconBtn icon="back" title="목록" @click="router.push('/board')" />
|
||||
<div class="right-actions">
|
||||
<!-- 관리자 제한/해제 -->
|
||||
<button v-if="isAdmin && !post.blocked" type="button" class="warn" @click="blockPost">열람 제한</button>
|
||||
<button v-if="isAdmin && post.blocked" type="button" class="warn" @click="unblockPost">제한 해제</button>
|
||||
<!-- 작성자/관리자 수정·삭제 -->
|
||||
<template v-if="canModifyPost && !restricted">
|
||||
<IconBtn v-if="!post.blocked" icon="edit" title="수정" @click="router.push(`/board/${post.id}/edit`)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" @click="removePost" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 댓글 (제한 글은 비관리자에게 숨김, 댓글 작성은 제한 글에서 불가) -->
|
||||
<section v-if="!restricted" class="comments">
|
||||
<h2>댓글 {{ post.comments.length }}</h2>
|
||||
|
||||
<ul class="comment-list">
|
||||
<li v-for="c in post.comments" :key="c.id" class="comment">
|
||||
<div class="comment-head">
|
||||
<strong>{{ c.authorName }}</strong>
|
||||
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
|
||||
<span v-if="canModifyComment(c)" class="comment-del">
|
||||
<IconBtn icon="trash" title="댓글 삭제" variant="danger" size="sm" @click="removeComment(c)" />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownViewer :content="c.content" class="comment-body" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!post.comments.length" class="msg">첫 댓글을 남겨보세요.</p>
|
||||
|
||||
<form v-if="!post.blocked" class="comment-form" @submit.prevent="addComment">
|
||||
<MarkdownEditor
|
||||
v-model="commentText"
|
||||
height="160px"
|
||||
initial-edit-type="wysiwyg"
|
||||
placeholder="댓글을 입력하세요"
|
||||
/>
|
||||
<div class="comment-submit">
|
||||
<IconBtn icon="send" title="댓글 등록" variant="primary" type="submit" :disabled="submitting" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.post-head {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 0 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.8rem;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.admin-banner {
|
||||
background: rgba(192, 57, 43, 0.1);
|
||||
border: 1px solid #c0392b;
|
||||
color: #c0392b;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.restricted {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: #c0392b;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
min-height: 120px;
|
||||
padding: 0.5rem 0 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.warn {
|
||||
border-color: #e67e22;
|
||||
color: #e67e22;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.right-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.comments {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.comments h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.comment-list {
|
||||
list-style: none;
|
||||
}
|
||||
.comment {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.comment-date {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.comment-del {
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.comment-body {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.comment-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.comment-submit {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.msg {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
import { formatRelative } from '@/utils/datetime'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
|
||||
|
||||
const posts = ref([])
|
||||
const tags = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const totalPages = ref(0)
|
||||
const totalElements = ref(0)
|
||||
|
||||
const activeTag = ref('')
|
||||
const activeKeyword = ref('')
|
||||
const activeSearchType = ref('title')
|
||||
|
||||
// 검색 모달 상태
|
||||
const searchOpen = ref(false)
|
||||
const modalKeyword = ref('')
|
||||
const modalSearchType = ref('title')
|
||||
|
||||
const SEARCH_LABELS = { title: '제목', content: '본문', comment: '댓글' }
|
||||
const searchTypeLabel = computed(() => SEARCH_LABELS[activeSearchType.value] || '제목')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await boardApi.list({
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
tag: activeTag.value,
|
||||
keyword: activeKeyword.value,
|
||||
searchType: activeSearchType.value,
|
||||
})
|
||||
posts.value = res.content
|
||||
totalPages.value = res.totalPages
|
||||
totalElements.value = res.totalElements
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '목록을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
tags.value = await boardApi.tags()
|
||||
} catch {
|
||||
tags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// URL 쿼리 → 상태 동기화 (뒤로가기 시 검색 조건/페이지 복원)
|
||||
function syncFromQuery() {
|
||||
page.value = Number(route.query.page) || 1
|
||||
activeTag.value = route.query.tag || ''
|
||||
activeKeyword.value = route.query.keyword || ''
|
||||
activeSearchType.value = route.query.searchType || 'title'
|
||||
}
|
||||
|
||||
// 상태 → URL 쿼리 반영 (빈 값은 생략). 쿼리 변경을 watch 가 감지해 load 수행
|
||||
function pushQuery({ page: pg = 1, tag = '', keyword = '', searchType = 'title' }) {
|
||||
const q = {}
|
||||
if (pg && pg > 1) q.page = String(pg)
|
||||
if (tag) q.tag = tag
|
||||
if (keyword) {
|
||||
q.keyword = keyword
|
||||
q.searchType = searchType || 'title'
|
||||
}
|
||||
router.push({ query: q })
|
||||
}
|
||||
|
||||
function filterByTag(tag) {
|
||||
const next = activeTag.value === tag ? '' : tag
|
||||
pushQuery({ page: 1, tag: next, keyword: activeKeyword.value, searchType: activeSearchType.value })
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
if (p < 1 || p > totalPages.value || p === page.value) return
|
||||
pushQuery({ page: p, tag: activeTag.value, keyword: activeKeyword.value, searchType: activeSearchType.value })
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
modalKeyword.value = activeKeyword.value
|
||||
modalSearchType.value = activeSearchType.value || 'title'
|
||||
searchOpen.value = true
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
searchOpen.value = false
|
||||
pushQuery({ page: 1, tag: activeTag.value, keyword: modalKeyword.value.trim(), searchType: modalSearchType.value })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
pushQuery({ page: 1, tag: activeTag.value })
|
||||
}
|
||||
|
||||
// 목록 표시 순번: 전체건수 기준 내림차순 (최신글이 큰 번호)
|
||||
function rowNumber(index) {
|
||||
return totalElements.value - (page.value - 1) * size.value - index
|
||||
}
|
||||
|
||||
// 쿼리가 바뀔 때마다(검색·페이지 이동·뒤로가기 포함) 상태 동기화 후 재조회
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
syncFromQuery()
|
||||
load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(loadTags)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="board">
|
||||
<header class="board-head">
|
||||
<h1>자유게시판</h1>
|
||||
<IconBtn icon="edit" title="글쓰기" variant="primary" @click="router.push('/board/write')" />
|
||||
</header>
|
||||
|
||||
<div v-if="tags.length" class="tag-filter">
|
||||
<button
|
||||
v-for="t in tags"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="tag-chip"
|
||||
:class="{ active: activeTag === t }"
|
||||
@click="filterByTag(t)"
|
||||
>{{ t }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 검색 적용 표시 -->
|
||||
<div v-if="activeKeyword" class="active-search">
|
||||
<span>{{ searchTypeLabel }} 검색: "{{ activeKeyword }}"</span>
|
||||
<IconBtn icon="close" title="검색 해제" size="sm" @click="clearSearch" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<table v-else-if="posts.length" class="post-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-id">번호</th>
|
||||
<th>제목</th>
|
||||
<th class="col-author">작성자</th>
|
||||
<th class="col-num">조회</th>
|
||||
<th class="col-date">작성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(p, index) in posts" :key="p.id" @click="router.push(`/board/${p.id}`)">
|
||||
<td class="col-id">{{ rowNumber(index) }}</td>
|
||||
<td>
|
||||
<template v-if="p.blocked && !isAdmin">
|
||||
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="title">{{ p.title }}</span>
|
||||
<span v-if="p.blocked" class="blocked-badge">제한</span>
|
||||
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="col-author">{{ p.authorName }}</td>
|
||||
<td class="col-num">{{ p.viewCount }}</td>
|
||||
<td class="col-date">{{ formatRelative(p.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="!loading" class="msg">게시글이 없습니다.</p>
|
||||
|
||||
<!-- 하단: 페이징과 같은 행에 돋보기(검색) 버튼 -->
|
||||
<div class="list-footer">
|
||||
<nav v-if="totalPages > 1" class="pagination">
|
||||
<IconBtn icon="chevronLeft" title="이전" size="sm" :disabled="page <= 1" @click="goPage(page - 1)" />
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
type="button"
|
||||
class="page-num"
|
||||
:class="{ active: p === page }"
|
||||
@click="goPage(p)"
|
||||
>{{ p }}</button>
|
||||
<IconBtn icon="chevronRight" title="다음" size="sm" :disabled="page >= totalPages" @click="goPage(page + 1)" />
|
||||
</nav>
|
||||
<IconBtn icon="search" title="검색" class="search-btn" @click="openSearch" />
|
||||
</div>
|
||||
|
||||
<!-- 검색 모달 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="searchOpen" class="modal-backdrop" @click.self="searchOpen = false">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="검색">
|
||||
<button class="close" type="button" aria-label="닫기" @click="searchOpen = false">×</button>
|
||||
<form class="search-form" @submit.prevent="doSearch">
|
||||
<select v-model="modalSearchType" class="search-type">
|
||||
<option value="title">제목</option>
|
||||
<option value="content">본문</option>
|
||||
<option value="comment">댓글</option>
|
||||
</select>
|
||||
<input v-model="modalKeyword" type="text" placeholder="검색어를 입력하세요" />
|
||||
<IconBtn icon="search" title="검색" variant="primary" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.board-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;
|
||||
}
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tag-chip {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.tag-chip.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.active-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.active-search .clear {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.post-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.post-table th,
|
||||
.post-table td {
|
||||
padding: 0.6rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.post-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.post-table tbody tr:hover {
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.col-id,
|
||||
.col-num {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.col-author {
|
||||
width: 100px;
|
||||
}
|
||||
.col-date {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.comment-count {
|
||||
margin-left: 0.3rem;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.blocked-msg {
|
||||
color: #c0392b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.blocked-badge {
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid #c0392b;
|
||||
border-radius: 3px;
|
||||
color: #c0392b;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
|
||||
.list-footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.4rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.page-num.active {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.search-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.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: 420px;
|
||||
padding: 2.5rem 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;
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.search-type {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 모바일: 표를 2줄(제목 / 메타) 카드로 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.board {
|
||||
max-width: 100%;
|
||||
}
|
||||
.post-table thead {
|
||||
display: none;
|
||||
}
|
||||
.post-table,
|
||||
.post-table tbody,
|
||||
.post-table tr,
|
||||
.post-table td {
|
||||
display: block;
|
||||
}
|
||||
.post-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.15rem;
|
||||
padding: 0.7rem 0.2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.post-table td {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: auto !important;
|
||||
text-align: left !important;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* 제목(2번째 셀): 첫 줄 전체 */
|
||||
.post-table td:nth-child(2) {
|
||||
order: 0;
|
||||
width: 100% !important;
|
||||
font-size: 0.97rem;
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.post-table .col-id {
|
||||
order: 1;
|
||||
}
|
||||
.post-table .col-id::before {
|
||||
content: '#';
|
||||
}
|
||||
.post-table .col-author {
|
||||
order: 2;
|
||||
}
|
||||
.post-table .col-num {
|
||||
order: 3;
|
||||
}
|
||||
.post-table .col-num::before {
|
||||
content: '· 조회 ';
|
||||
}
|
||||
.post-table .col-date {
|
||||
order: 4;
|
||||
}
|
||||
.post-table .col-date::before {
|
||||
content: '· ';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { boardApi } from '@/api/boardApi'
|
||||
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const editId = route.params.id || null
|
||||
const isEdit = computed(() => !!editId)
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const tagGroups = ref([])
|
||||
const selectedTagIds = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 카테고리 구분 없이 태그만 평탄화
|
||||
const allTags = computed(() => tagGroups.value.flatMap((g) => g.tags))
|
||||
|
||||
function toggleTag(id) {
|
||||
const i = selectedTagIds.value.indexOf(id)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(id)
|
||||
}
|
||||
|
||||
async function loadTagGroups() {
|
||||
try {
|
||||
tagGroups.value = await boardApi.tagGroups()
|
||||
} catch {
|
||||
tagGroups.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadForEdit() {
|
||||
loading.value = true
|
||||
try {
|
||||
const p = await boardApi.get(editId)
|
||||
title.value = p.title
|
||||
content.value = p.content
|
||||
// 게시글의 태그(이름) → 현재 태그 목록에서 id 매핑
|
||||
const nameToId = {}
|
||||
tagGroups.value.forEach((c) => c.tags.forEach((t) => (nameToId[t.name] = t.id)))
|
||||
selectedTagIds.value = (p.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '게시글을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!title.value.trim() || !content.value.trim()) {
|
||||
error.value = '제목과 내용을 입력하세요.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
const payload = {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
try {
|
||||
const saved = isEdit.value
|
||||
? await boardApi.update(editId, payload)
|
||||
: await boardApi.create(payload)
|
||||
router.replace(`/board/${saved.id}`)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) router.replace(`/board/${editId}`)
|
||||
else router.replace('/board')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTagGroups()
|
||||
if (isEdit.value) await loadForEdit()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="write">
|
||||
<h1>{{ isEdit ? '글 수정' : '글쓰기' }}</h1>
|
||||
|
||||
<form class="write-form" @submit.prevent="submit">
|
||||
<input v-model="title" type="text" placeholder="제목" maxlength="200" :disabled="loading" />
|
||||
|
||||
<!-- 태그 선택 (DB 등록 태그를 뱃지로 토글) -->
|
||||
<div class="tag-select">
|
||||
<div v-if="!allTags.length" class="hint">
|
||||
등록된 태그가 없습니다. (관리자가 [태그 관리]에서 추가할 수 있습니다)
|
||||
</div>
|
||||
<button
|
||||
v-for="t in allTags"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tag-badge"
|
||||
:class="{ active: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>{{ t.name }}</button>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor v-model="content" height="420px" placeholder="내용을 입력하세요" />
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<div class="buttons">
|
||||
<IconBtn icon="close" title="취소" @click="cancel" />
|
||||
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="loading" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.write {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.write-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.write-form input[type='text'] {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.tag-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.tag-badge {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tag-badge:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.5rem 1.2rem;
|
||||
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.primary {
|
||||
border-color: hsla(160, 100%, 37%, 1);
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.msg.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user