Merge branch 'dev' into main

가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
This commit is contained in:
ByungCheol
2026-05-31 15:45:07 +09:00
39 changed files with 7385 additions and 111 deletions
+85
View File
@@ -0,0 +1,85 @@
# Slim Budget — 프론트엔드 (sb_pt)
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
## 기술 스택
- **Vue 3** (`<script setup>` Composition API) + **Vite**
- **Pinia** (상태관리), **Vue Router** (라우팅)
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
## 브랜딩
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
## 디렉터리 구조
```
src/
├── api/ # 백엔드 연동 (axios)
│ ├── http.js # 공통 인스턴스 (Authorization 주입, 401 처리)
│ ├── authApi.js # 로그인/회원가입/세션
│ ├── boardApi.js # 게시판
│ ├── adminApi.js # 관리자(태그 카테고리)
│ └── accountApi.js # 가계부 전체
├── stores/ # Pinia (auth, ui, user)
├── router/index.js # 라우트 + 인증 가드
├── components/
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
│ ├── editor/ # MarkdownEditor / MarkdownViewer
│ ├── LoginModal.vue / SignupModal.vue
├── views/
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
│ ├── UsersView.vue # 사용자 관리
│ ├── board/ # 게시판 (목록/상세/작성)
│ ├── account/ # 가계부 (아래 참조)
│ └── admin/ # 태그 관리(관리자)
└── assets/main.css # 전역 스타일
```
## 인증
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
## 레이아웃 / 반응형
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
## 게시판 (`/board`)
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
- 댓글, 태그, 관리자 **열람 제한/해제**
## 가계부 (`/account`)
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
| 경로 | 화면 | 설명 |
|------|------|------|
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
### 잔액·순자산 모델 (표시)
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
## 개발/빌드
```bash
npm install
npm run dev # 개발 서버
npm run build # dist/ 정적 빌드
```
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
+4 -2
View File
@@ -2,9 +2,11 @@
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="alternate icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/logo.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>Slim Budget</title>
</head>
<body>
<div id="app"></div>
+144
View File
@@ -8,8 +8,11 @@
"name": "sb_pt",
"version": "0.0.0",
"dependencies": {
"@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
"axios": "^1.16.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
@@ -1391,6 +1394,31 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@toast-ui/editor": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@toast-ui/editor/-/editor-3.2.2.tgz",
"integrity": "sha512-ASX7LFjN2ZYQJrwmkUajPs7DRr9FsM1+RQ82CfTO0Y5ZXorBk1VZS4C2Dpxinx9kl55V4F8/A2h2QF4QMDtRbA==",
"license": "MIT",
"dependencies": {
"dompurify": "^2.3.3",
"prosemirror-commands": "^1.1.9",
"prosemirror-history": "^1.1.3",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.4",
"prosemirror-model": "^1.14.1",
"prosemirror-state": "^1.3.4",
"prosemirror-view": "^1.18.7"
}
},
"node_modules/@toast-ui/editor-plugin-code-syntax-highlight": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@toast-ui/editor-plugin-code-syntax-highlight/-/editor-plugin-code-syntax-highlight-3.1.0.tgz",
"integrity": "sha512-OgX5pZiTnHREoTTXDAFu1k6RzEspGOxeJNRlt/Lnoi1GvLbIpUTTbBcls9becpXT/Qdls++8G3r5C60cVdellA==",
"license": "MIT",
"dependencies": {
"prismjs": "^1.23.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2135,6 +2163,12 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
"license": "(MPL-2.0 OR Apache-2.0)"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3569,6 +3603,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/oxlint": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.60.0.tgz",
@@ -3806,6 +3846,98 @@
"node": ">= 0.8.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.7",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3908,6 +4040,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -4568,6 +4706,12 @@
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+3
View File
@@ -12,8 +12,11 @@
"lint:eslint": "eslint . --fix --cache"
},
"dependencies": {
"@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
"axios": "^1.16.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<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>

After

Width:  |  Height:  |  Size: 358 B

+88 -60
View File
@@ -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>
+143
View File
@@ -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}`)
},
}
+32
View File
@@ -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 })
},
}
+17
View File
@@ -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')
},
}
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+184
View File
@@ -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>
+174
View File
@@ -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>
+55
View File
@@ -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>
+65
View File
@@ -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>
+21
View File
@@ -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>
+112
View File
@@ -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>
+99
View File
@@ -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>
+116
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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
}
}
+43
View File
@@ -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,
}
})
+31
View File
@@ -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}`
}
-15
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+862
View File
@@ -0,0 +1,862 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const unit = ref('MONTH') // DAY / WEEK / MONTH / YEAR
const tableYear = ref(now.getFullYear()) // 월별 총액 표 전용 연도
const currentYear = now.getFullYear()
const loading = ref(false)
const error = ref(null)
const budgetTotal = ref(0)
const spentTotal = ref(0)
const monthIncome = ref(0)
const monthExpense = ref(0)
const barData = ref([]) // [{bucket, budget, expense}]
const monthlyStats = ref([]) // MONTH unit of year (income/expense)
const catType = ref('EXPENSE') // 분류별 파이차트 구분
const catData = ref([]) // [{category, total}]
const trendData = ref([]) // [{month, netWorth}]
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate()
}
function range(a, b) {
const r = []
for (let i = a; i <= b; i++) r.push(i)
return r
}
/* ===== 예산 대비 지출 도넛 ===== */
const R = 52
const C = 2 * Math.PI * R
const budgetRatio = computed(() => (budgetTotal.value > 0 ? Math.min(spentTotal.value / budgetTotal.value, 1) : 0))
const budgetPct = computed(() => (budgetTotal.value > 0 ? Math.round((spentTotal.value / budgetTotal.value) * 100) : 0))
const donutDash = computed(() => `${budgetRatio.value * C} ${C}`)
const donutColor = computed(() => {
const r = budgetTotal.value > 0 ? spentTotal.value / budgetTotal.value : 0
return r >= 1 ? '#c0392b' : r >= 0.8 ? '#e67e22' : '#2e7d32'
})
/* ===== 수입/지출 비율 ===== */
const ieTotal = computed(() => monthIncome.value + monthExpense.value)
const incomePct = computed(() => (ieTotal.value > 0 ? (monthIncome.value / ieTotal.value) * 100 : 0))
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
const bars = computed(() => {
const map = {}
barData.value.forEach((d) => (map[d.bucket] = d))
let buckets
if (unit.value === 'DAY') buckets = range(1, daysInMonth(year.value, month.value)).map(String)
else if (unit.value === 'WEEK') buckets = range(1, Math.ceil(daysInMonth(year.value, month.value) / 7)).map(String)
else if (unit.value === 'MONTH') buckets = range(1, 12).map(String)
else buckets = barData.value.map((d) => d.bucket) // YEAR: 존재하는 연도
return buckets.map((b) => ({
label: unit.value === 'WEEK' ? `${b}` : b,
budget: map[b]?.budget || 0,
expense: map[b]?.expense || 0,
}))
})
const barMax = computed(() => Math.max(1, ...bars.value.flatMap((b) => [b.budget, b.expense])))
/* ===== 막대 차트 툴팁 (지출/예산 비율) ===== */
const chartRef = ref(null)
const tip = reactive({ show: false, x: 0, y: 0, label: '', budget: 0, expense: 0, ratio: 0 })
function showTip(b) {
tip.label = b.label
tip.budget = b.budget
tip.expense = b.expense
tip.ratio = b.budget > 0 ? Math.round((b.expense / b.budget) * 100) : 0
tip.show = true
}
function moveTip(e) {
const r = chartRef.value?.getBoundingClientRect()
if (!r) return
tip.x = e.clientX - r.left
tip.y = e.clientY - r.top
}
function hideTip() {
tip.show = false
}
/* ===== 분류별 파이차트 ===== */
const PIE_R = 52
const PIE_C = 2 * Math.PI * PIE_R
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d']
const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0))
const pieSegments = computed(() => {
const total = catTotal.value
if (total <= 0) return []
let acc = 0
return catData.value.map((d, i) => {
const len = (d.total / total) * PIE_C
const seg = {
category: d.category,
total: d.total,
pct: Math.round((d.total / total) * 100),
color: PIE_COLORS[i % PIE_COLORS.length],
len,
offset: -acc,
}
acc += len
return seg
})
})
const catHover = ref(-1)
/* ===== 순자산 추이 ===== */
const trendView = computed(() => {
const data = trendData.value
if (!data.length) return { pts: [], line: '', area: '', zeroY: null, min: 0, max: 0 }
const vals = data.map((d) => d.netWorth)
const min = Math.min(...vals, 0)
const max = Math.max(...vals, 0)
const span = max - min || 1
const n = data.length
const pts = data.map((d, i) => ({
x: n === 1 ? 50 : (i / (n - 1)) * 100,
y: 100 - ((d.netWorth - min) / span) * 100,
month: d.month,
netWorth: d.netWorth,
label: d.month.slice(5), // MM
}))
const line = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const area = `${pts[0].x.toFixed(2)},100 ` + line + ` ${pts[n - 1].x.toFixed(2)},100`
const zeroY = min < 0 && max > 0 ? 100 - ((0 - min) / span) * 100 : null
return { pts, line, area, zeroY, min, max }
})
const trendTip = reactive({ show: false, x: 0, y: 0, month: '', netWorth: 0 })
function showTrendTip(p) {
trendTip.month = p.month
trendTip.netWorth = p.netWorth
trendTip.x = p.x
trendTip.y = p.y
trendTip.show = true
}
function hideTrendTip() {
trendTip.show = false
}
async function loadCat() {
try {
catData.value = await accountApi.categoryStats({ type: catType.value, year: year.value, month: month.value })
} catch {
catData.value = []
}
}
function setCatType(t) {
catType.value = t
loadCat()
}
async function loadTrend() {
try {
trendData.value = await accountApi.netWorthTrend({ months: 12 })
} catch {
trendData.value = []
}
}
/* ===== 월별 총액 목록 ===== */
const monthlyRows = computed(() => {
const map = {}
monthlyStats.value.forEach((d) => (map[d.bucket] = d))
return range(1, 12).map((m) => {
const d = map[String(m)] || { income: 0, expense: 0 }
return { month: m, income: d.income, expense: d.expense, net: d.income - d.expense }
})
})
async function loadBar() {
try {
barData.value = await accountApi.budgetPeriod({ unit: unit.value, year: year.value, month: month.value })
} catch {
barData.value = []
}
}
async function load() {
loading.value = true
error.value = null
try {
const [status, summary] = await Promise.all([
accountApi.budgetStatus({ year: year.value, month: month.value }),
accountApi.summary({ year: year.value, month: month.value }),
loadBar(),
loadCat(),
])
budgetTotal.value = status.reduce((s, x) => s + x.monthlyBudget, 0)
spentTotal.value = status.reduce((s, x) => s + x.spent, 0)
monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function setUnit(u) {
unit.value = u
loadBar()
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else month.value -= 1
load()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else month.value += 1
load()
}
// 월별 총액 표: 전용 연도
async function loadMonthly() {
try {
monthlyStats.value = await accountApi.stats({ unit: 'MONTH', year: tableYear.value })
} catch {
monthlyStats.value = []
}
}
function prevTableYear() {
tableYear.value -= 1
loadMonthly()
}
function nextTableYear() {
if (tableYear.value >= currentYear) return
tableYear.value += 1
loadMonthly()
}
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영 (중복 없이)
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 대시보드 조회를 막지 않는다
}
load()
loadMonthly()
loadTrend()
})
</script>
<template>
<section class="dash">
<header class="head">
<h1>가계부 대시보드</h1>
</header>
<div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<template v-else>
<!-- 상단: 예산 대비 지출(도넛) + 수입 대비 지출 -->
<div class="top-grid">
<div class="panel donut-panel">
<h2>예산 대비 지출</h2>
<div v-if="budgetTotal > 0" class="donut-wrap">
<svg viewBox="0 0 120 120" class="donut">
<circle cx="60" cy="60" :r="R" fill="none" stroke="var(--color-background-mute)" stroke-width="14" />
<circle
cx="60" cy="60" :r="R" fill="none" :stroke="donutColor" stroke-width="14"
:stroke-dasharray="donutDash" stroke-linecap="round" transform="rotate(-90 60 60)"
/>
<text x="60" y="56" text-anchor="middle" class="donut-pct">{{ budgetPct }}%</text>
<text x="60" y="74" text-anchor="middle" class="donut-sub">지출/예산</text>
</svg>
<div class="donut-legend">
<div>지출 <b>{{ won(spentTotal) }}</b></div>
<div>예산 <b>{{ won(budgetTotal) }}</b></div>
<div :class="budgetTotal - spentTotal < 0 ? 'neg' : ''">
{{ budgetTotal - spentTotal >= 0 ? '잔여' : '초과' }} <b>{{ won(Math.abs(budgetTotal - spentTotal)) }}</b>
</div>
</div>
</div>
<p v-else class="empty">설정된 예산이 없습니다.</p>
</div>
<div class="panel">
<h2>수입 대비 지출</h2>
<div class="ie">
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
<div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
</div>
</div>
</div>
<!-- 분류별 파이차트 -->
<div class="panel">
<div class="panel-head">
<h2>분류별 {{ catType === 'EXPENSE' ? '지출' : '수입' }}</h2>
<div class="unit-tabs">
<button type="button" :class="{ active: catType === 'EXPENSE' }" @click="setCatType('EXPENSE')">지출</button>
<button type="button" :class="{ active: catType === 'INCOME' }" @click="setCatType('INCOME')">수입</button>
</div>
</div>
<div v-if="pieSegments.length" class="pie-wrap">
<svg viewBox="0 0 120 120" class="pie">
<circle cx="60" cy="60" :r="PIE_R" fill="none" stroke="var(--color-background-mute)" stroke-width="20" />
<circle
v-for="(s, i) in pieSegments" :key="i"
cx="60" cy="60" :r="PIE_R" fill="none" :stroke="s.color"
:stroke-width="catHover === i ? 24 : 20"
:stroke-dasharray="`${s.len} ${PIE_C - s.len}`" :stroke-dashoffset="s.offset"
transform="rotate(-90 60 60)" class="pie-seg"
@mouseenter="catHover = i" @mouseleave="catHover = -1"
/>
<text x="60" y="57" text-anchor="middle" class="pie-center">{{ won(catTotal) }}</text>
<text x="60" y="72" text-anchor="middle" class="pie-center-sub"> {{ catType === 'EXPENSE' ? '지출' : '수입' }}</text>
</svg>
<ul class="pie-legend">
<li v-for="(s, i) in pieSegments" :key="i" :class="{ hover: catHover === i }"
@mouseenter="catHover = i" @mouseleave="catHover = -1">
<span class="sw" :style="{ background: s.color }"></span>
<span class="cat">{{ s.category }}</span>
<span class="pct">{{ s.pct }}%</span>
<span class="amt">{{ won(s.total) }}</span>
</li>
</ul>
</div>
<p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
</div>
<!-- 기간별 예산 대비 지출 막대 차트 -->
<div class="panel">
<div class="panel-head">
<h2>기간별 예산 대비 지출</h2>
<div class="unit-tabs">
<button type="button" :class="{ active: unit === 'DAY' }" @click="setUnit('DAY')">일별</button>
<button type="button" :class="{ active: unit === 'WEEK' }" @click="setUnit('WEEK')">주별</button>
<button type="button" :class="{ active: unit === 'MONTH' }" @click="setUnit('MONTH')">월별</button>
<button type="button" :class="{ active: unit === 'YEAR' }" @click="setUnit('YEAR')">년별</button>
</div>
</div>
<div class="legend-inline">
<span class="dot budget"></span>예산 <span class="dot expense"></span>지출
</div>
<div v-if="bars.length" ref="chartRef" class="chart-wrap" @mousemove="moveTip" @mouseleave="hideTip">
<div class="bar-chart">
<div v-for="(b, i) in bars" :key="i" class="bar-col" @mouseenter="showTip(b)">
<div class="bars">
<div class="bar budget" :style="{ height: (b.budget / barMax) * 100 + '%' }"></div>
<div class="bar expense" :style="{ height: (b.expense / barMax) * 100 + '%' }"></div>
</div>
<span class="bar-label">{{ b.label }}</span>
</div>
</div>
<div v-if="tip.show" class="tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
<div class="t-label">{{ tip.label }}{{ unit === 'YEAR' ? '년' : unit === 'MONTH' ? '월' : '' }}</div>
<div><span class="dot budget"></span>예산 {{ won(tip.budget) }}</div>
<div><span class="dot expense"></span>지출 {{ won(tip.expense) }}</div>
<div class="t-ratio" :class="tip.ratio >= 100 ? 'over' : ''">예산 대비 {{ tip.ratio }}%</div>
</div>
</div>
<p v-else class="empty">데이터가 없습니다.</p>
</div>
<!-- 순자산 추이 (최근 12개월) -->
<div class="panel">
<h2>순자산 추이 <span class="sub-note">최근 12개월</span></h2>
<div v-if="trendView.pts.length" class="trend-wrap" @mouseleave="hideTrendTip">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="trend-svg">
<line v-if="trendView.zeroY !== null" x1="0" :y1="trendView.zeroY" x2="100" :y2="trendView.zeroY"
class="zero-line" vector-effect="non-scaling-stroke" />
<polygon :points="trendView.area" class="trend-area" />
<polyline :points="trendView.line" class="trend-line" vector-effect="non-scaling-stroke" />
</svg>
<!-- /호버 마커 (HTML 오버레이) -->
<div
v-for="(p, i) in trendView.pts" :key="i" class="trend-dot"
:style="{ left: p.x + '%', top: p.y + '%' }"
@mouseenter="showTrendTip(p)"
></div>
<div class="trend-labels">
<span v-for="(p, i) in trendView.pts" :key="i" class="t-axis" :style="{ left: p.x + '%' }">{{ p.label }}</span>
</div>
<div v-if="trendTip.show" class="tooltip trend-tip" :style="{ left: trendTip.x + '%', top: trendTip.y + '%' }">
<div class="t-label">{{ trendTip.month }}</div>
<div :class="trendTip.netWorth < 0 ? 'neg' : ''">순자산 {{ won(trendTip.netWorth) }}</div>
</div>
</div>
<p v-else class="empty">데이터가 없습니다.</p>
</div>
<!-- 월별 수입/지출 총액 목록 -->
<div class="panel">
<div class="panel-head">
<h2>월별 총액</h2>
<div class="year-nav">
<IconBtn icon="chevronLeft" title="이전 연도" size="sm" @click="prevTableYear" />
<span class="year-label">{{ tableYear }}</span>
<IconBtn icon="chevronRight" title="다음 연도" size="sm" :disabled="tableYear >= currentYear" @click="nextTableYear" />
</div>
</div>
<table class="monthly">
<thead>
<tr><th></th><th class="num">수입</th><th class="num">지출</th><th class="num">수지</th></tr>
</thead>
<tbody>
<tr v-for="r in monthlyRows" :key="r.month">
<td>{{ r.month }}</td>
<td class="num income">{{ won(r.income) }}</td>
<td class="num expense">{{ won(r.expense) }}</td>
<td class="num" :class="r.net < 0 ? 'expense' : 'income'">{{ won(r.net) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<style scoped>
.dash {
max-width: 760px;
margin: 0 auto;
}
.head {
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.month-nav button {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
.panel {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.top-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.top-grid .panel {
margin-bottom: 1rem;
}
.donut-wrap {
display: flex;
align-items: center;
gap: 1rem;
}
.donut {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.donut-pct {
font-size: 20px;
font-weight: 700;
fill: var(--color-heading);
}
.donut-sub {
font-size: 9px;
fill: var(--color-text);
opacity: 0.6;
}
.donut-legend {
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.donut-legend .neg b {
color: #c0392b;
}
.ie-row {
display: flex;
justify-content: space-between;
padding: 0.2rem 0;
font-size: 0.9rem;
}
.ie-row.net {
border-top: 1px solid var(--color-border);
margin-top: 0.3rem;
padding-top: 0.4rem;
}
.income {
color: #2e7d32;
}
.expense {
color: #c0392b;
}
.ie-bar {
height: 8px;
background: #c0392b;
border-radius: 999px;
overflow: hidden;
margin: 0.4rem 0;
}
.ie-fill.income {
height: 100%;
background: #2e7d32;
}
.unit-tabs {
display: flex;
gap: 0.2rem;
}
.unit-tabs button {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.unit-tabs button.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.legend-inline {
font-size: 0.78rem;
opacity: 0.75;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.3rem;
}
.dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
margin-left: 0.5rem;
}
.dot.income {
background: #2e7d32;
}
.dot.expense {
background: #c0392b;
}
.dot.budget {
background: #3b82a6;
}
.chart-wrap {
position: relative;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 160px;
overflow-x: auto;
padding-top: 0.5rem;
}
.bar-col {
flex: 1;
min-width: 16px;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: 1px;
width: 100%;
justify-content: center;
}
.bar {
width: 45%;
min-height: 1px;
border-radius: 2px 2px 0 0;
}
.bar.income {
background: #2e7d32;
}
.bar.budget {
background: #3b82a6;
}
.bar.expense {
background: #c0392b;
}
.tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 12px));
pointer-events: none;
background: #1e1e1e;
color: #fff;
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
z-index: 5;
line-height: 1.5;
}
.tooltip .t-label {
font-weight: 700;
margin-bottom: 0.2rem;
}
.tooltip .dot {
margin-left: 0;
margin-right: 0.3rem;
}
.tooltip .t-ratio {
margin-top: 0.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 0.25rem;
font-weight: 700;
}
.tooltip .t-ratio.over {
color: #ff6b6b;
}
.bar-label {
font-size: 0.65rem;
opacity: 0.7;
margin-top: 0.2rem;
white-space: nowrap;
}
/* 분류별 파이차트 */
.pie-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.pie {
width: 100%;
max-width: 170px;
height: auto;
aspect-ratio: 1 / 1;
flex-shrink: 0;
}
.pie-seg {
cursor: pointer;
transition: stroke-width 0.12s ease;
}
.pie-center {
font-size: 13px;
font-weight: 700;
fill: var(--color-heading);
}
.pie-center-sub {
font-size: 8px;
fill: var(--color-text);
opacity: 0.6;
}
.pie-legend {
list-style: none;
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.1rem 1rem;
font-size: 0.85rem;
}
.pie-legend li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.3rem;
border-radius: 4px;
}
.pie-legend li.hover {
background: var(--color-background-mute);
}
.pie-legend .sw {
width: 11px;
height: 11px;
border-radius: 2px;
flex-shrink: 0;
}
.pie-legend .cat {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pie-legend .pct {
opacity: 0.65;
min-width: 2.5rem;
text-align: right;
}
.pie-legend .amt {
font-weight: 600;
min-width: 5rem;
text-align: right;
}
/* 순자산 추이 */
.sub-note {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.6;
}
.trend-wrap {
position: relative;
height: 170px;
padding-bottom: 1.1rem;
}
.trend-svg {
width: 100%;
height: 150px;
overflow: visible;
}
.trend-area {
fill: hsla(160, 100%, 37%, 0.12);
}
.trend-line {
fill: none;
stroke: hsla(160, 100%, 37%, 1);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.zero-line {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.trend-dot {
position: absolute;
width: 9px;
height: 9px;
margin: -4.5px 0 0 -4.5px;
border-radius: 50%;
background: var(--color-background);
border: 2px solid hsla(160, 100%, 37%, 1);
cursor: pointer;
}
.trend-dot:hover {
transform: scale(1.3);
}
.trend-labels {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1rem;
}
.t-axis {
position: absolute;
transform: translateX(-50%);
font-size: 0.62rem;
opacity: 0.6;
white-space: nowrap;
}
.trend-tip {
position: absolute;
}
.trend-tip .neg {
color: #ff6b6b;
}
.year-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-nav button {
padding: 0.2rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.year-nav button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.year-label {
font-weight: 600;
min-width: 4rem;
text-align: center;
}
.monthly {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.monthly th,
.monthly td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.monthly .num {
text-align: right;
}
.empty {
font-size: 0.85rem;
opacity: 0.6;
padding: 1rem 0;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
@media (max-width: 768px) {
.dash {
max-width: 100%;
}
.top-grid {
grid-template-columns: 1fr;
}
.monthly th,
.monthly td {
padding: 0.4rem 0.3rem;
}
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const tags = ref([])
const loading = ref(false)
const error = ref(null)
const newName = ref('')
async function load() {
loading.value = true
error.value = null
try {
tags.value = await accountApi.tags()
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function addTag() {
const name = newName.value.trim()
if (!name) return
try {
await accountApi.createTag({ name })
newName.value = ''
await load()
} catch (e) {
alert(e.response?.data?.message || '추가에 실패했습니다.')
}
}
async function saveTag(tag) {
const name = (tag.name || '').trim()
if (!name) return
try {
await accountApi.updateTag(tag.id, { name })
await load()
} catch (e) {
alert(e.response?.data?.message || '수정에 실패했습니다.')
}
}
async function removeTag(tag) {
if (!confirm(`'${tag.name}' 태그를 삭제할까요? (내역에서 연결도 해제됩니다)`)) return
try {
await accountApi.removeTag(tag.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(load)
</script>
<template>
<section class="tag-admin">
<header class="head">
<h1>가계부 태그 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<p class="hint">내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.</p>
<form class="new-tag" @submit.prevent="addTag">
<input v-model="newName" type="text" placeholder="새 태그 이름 (예: 식비, 고정지출)" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="tags.length" class="tag-list">
<li v-for="t in tags" :key="t.id" class="tag-row">
<input v-model="t.name" class="tag-name" @keyup.enter="saveTag(t)" />
<IconBtn icon="check" title="저장" @click="saveTag(t)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeTag(t)" />
</li>
</ul>
<p v-else-if="!loading" class="msg">등록된 태그가 없습니다.</p>
</section>
</template>
<style scoped>
.tag-admin {
max-width: 560px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
}
h1 {
font-size: 1.5rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
button {
padding: 0.45rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
.new-tag {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.new-tag input {
flex: 1;
}
.tag-list {
list-style: none;
}
.tag-row {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.tag-name {
flex: 1;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
</style>
+898
View File
@@ -0,0 +1,898 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const entries = ref([])
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const loading = ref(false)
const error = ref(null)
// 검색·필터
const filterOpen = ref(false)
const filters = reactive({ keyword: '', type: '', category: '', walletId: '', tagId: '' })
const hasFilter = computed(() => !!(filters.keyword || filters.type || filters.category || filters.walletId || filters.tagId))
function filterParams() {
const p = {}
if (filters.keyword) p.keyword = filters.keyword.trim()
if (filters.type) p.type = filters.type
if (filters.category) p.category = filters.category
if (filters.walletId) p.walletId = filters.walletId
if (filters.tagId) p.tagId = filters.tagId
return p
}
function applyFilters() {
load()
}
function resetFilters() {
filters.keyword = ''
filters.type = ''
filters.category = ''
filters.walletId = ''
filters.tagId = ''
load()
}
// 추가/수정 모달
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
const isRepayment = computed(() => form.type === 'REPAYMENT')
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
const submitting = ref(false)
const formError = ref(null)
// 계좌/카드
const wallets = ref([])
async function loadWallets() {
try {
wallets.value = await accountApi.wallets()
} catch {
wallets.value = []
}
}
// 분류(카테고리)
const categories = ref([])
async function loadCategories() {
try {
categories.value = await accountApi.categories()
} catch {
categories.value = []
}
}
// 현재 구분(수입/지출)에 맞는 분류 + 편집 중 현재 값 보존
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
// 필터용 전체 분류명 (중복 제거)
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
// 모달 내 분류 인라인 추가
const addingCategory = ref(false)
const newCategoryName = ref('')
const catSubmitting = ref(false)
function startAddCategory() {
newCategoryName.value = ''
addingCategory.value = true
}
function cancelAddCategory() {
addingCategory.value = false
newCategoryName.value = ''
}
async function confirmAddCategory() {
const name = newCategoryName.value.trim()
if (!name) return
catSubmitting.value = true
try {
await accountApi.createCategory({ type: form.type, name })
await loadCategories()
form.category = name
cancelAddCategory()
} catch (e) {
if (e.response?.status === 409) {
// 이미 있는 분류면 그대로 선택
form.category = name
cancelAddCategory()
} else {
alert(e.response?.data?.message || '분류 추가에 실패했습니다.')
}
} finally {
catSubmitting.value = false
}
}
// 태그 (태그 관리에 등록된 태그 선택)
const tagOptions = ref([]) // [{ id, name }]
const selectedTagIds = ref([])
function toggleTag(id) {
const i = selectedTagIds.value.indexOf(id)
if (i >= 0) selectedTagIds.value.splice(i, 1)
else selectedTagIds.value.push(id)
}
async function loadTagOptions() {
try {
tagOptions.value = await accountApi.tags()
} catch {
tagOptions.value = []
}
}
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function formatDate(value) {
if (!value) return '-'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : `${d.getMonth() + 1}/${d.getDate()}`
}
function dotClass(type) {
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
}
function amountClass(type) {
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
}
function amountSign(type) {
return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-'
}
async function load() {
loading.value = true
error.value = null
try {
const listParams = { year: year.value, month: month.value, ...filterParams() }
const sumParams = { year: year.value, month: month.value }
const [list, sum] = await Promise.all([accountApi.list(listParams), accountApi.summary(sumParams)])
entries.value = list
summary.value = sum
} catch (e) {
error.value = e.response?.data?.message || '내역을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else {
month.value -= 1
}
load()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else {
month.value += 1
}
load()
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function openCreate() {
editId.value = null
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null })
selectedTagIds.value = []
cancelAddCategory()
formError.value = null
formOpen.value = true
}
function openEdit(e) {
editId.value = e.id
Object.assign(form, {
entryDate: e.entryDate,
type: e.type,
category: e.category || '',
amount: e.amount,
memo: e.memo || '',
walletId: e.walletId || '',
toWalletId: e.toWalletId || '',
})
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {}
tagOptions.value.forEach((t) => (nameToId[t.name] = t.id))
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
cancelAddCategory()
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.entryDate) {
formError.value = '거래일을 입력하세요.'
return
}
// 상환/납부: 원금=이체, 이자=지출 자동 분리
if (isRepayment.value) {
if (!form.walletId || !form.toWalletId) {
formError.value = '출금 계좌와 대상(대출/카드)을 선택하세요.'
return
}
if (form.walletId === form.toWalletId) {
formError.value = '출금/대상 계좌가 같을 수 없습니다.'
return
}
const principal = Number(form.principal) || 0
const interest = Number(form.interest) || 0
if (principal <= 0 && interest <= 0) {
formError.value = '원금 또는 이자를 입력하세요.'
return
}
submitting.value = true
try {
await accountApi.repayment({
entryDate: form.entryDate,
fromWalletId: form.walletId,
targetWalletId: form.toWalletId,
principal,
interest,
memo: form.memo || null,
})
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
return
}
if (form.amount == null || form.amount < 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
if (form.type === 'TRANSFER') {
if (!form.walletId || !form.toWalletId) {
formError.value = '이체는 출금/입금 계좌를 모두 선택하세요.'
return
}
if (form.walletId === form.toWalletId) {
formError.value = '출금/입금 계좌가 같을 수 없습니다.'
return
}
}
submitting.value = true
const payload = {
entryDate: form.entryDate,
type: form.type,
category: form.category || null,
amount: Number(form.amount),
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
tagIds: selectedTagIds.value,
}
try {
if (editId.value) await accountApi.update(editId.value, payload)
else await accountApi.create(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(e) {
if (!confirm('이 내역을 삭제하시겠습니까?')) return
try {
await accountApi.remove(e.id)
await load()
} catch (err) {
alert(err.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try {
await accountApi.runRecurrings()
} catch {
// 정기 거래 반영 실패는 가계부 조회를 막지 않는다
}
load()
loadTagOptions()
loadWallets()
loadCategories()
})
</script>
<template>
<section class="account">
<header class="account-head">
<h1>가계부</h1>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</header>
<div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<IconBtn
icon="filter" title="검색·필터" class="filter-toggle"
:variant="hasFilter ? 'primary' : 'default'" @click="filterOpen = !filterOpen"
/>
</div>
<div v-if="filterOpen" class="filter-bar">
<input
v-model="filters.keyword" type="text" class="f-keyword"
placeholder="메모·분류 검색" @keyup.enter="applyFilters"
/>
<select v-model="filters.type">
<option value="">구분 전체</option>
<option value="INCOME">수입</option>
<option value="EXPENSE">지출</option>
<option value="TRANSFER">이체</option>
</select>
<select v-model="filters.category">
<option value="">분류 전체</option>
<option v-for="c in allCategoryNames" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="filters.walletId">
<option value="">계좌 전체</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
</select>
<select v-model="filters.tagId">
<option value="">태그 전체</option>
<option v-for="t in tagOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
<IconBtn icon="search" title="적용" variant="primary" @click="applyFilters" />
<IconBtn icon="refresh" title="초기화" @click="resetFilters" />
</div>
<div class="summary">
<div class="card income">
<span class="label">수입</span>
<span class="value">+{{ won(summary.totalIncome) }}</span>
</div>
<div class="card expense">
<span class="label">지출</span>
<span class="value">-{{ won(summary.totalExpense) }}</span>
</div>
<div class="card balance">
<span class="label">잔액</span>
<span class="value">{{ won(summary.balance) }}</span>
</div>
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<table v-else-if="entries.length" class="entry-table">
<thead>
<tr>
<th class="col-date">날짜</th>
<th class="col-cat">분류</th>
<th>메모</th>
<th class="col-amount">금액</th>
<th class="col-act"></th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.id">
<td class="col-date">{{ formatDate(e.entryDate) }}</td>
<td class="col-cat">
<span class="type-dot" :class="dotClass(e.type)"></span>
<template v-if="e.type === 'TRANSFER'">이체</template>
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
</td>
<td class="memo">
<span v-if="e.type === 'TRANSFER'" class="row-wallet">{{ e.walletName }} {{ e.toWalletName }}</span>
<span v-else-if="e.walletName" class="row-wallet">{{ e.walletName }}</span>
{{ e.memo }}
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
</td>
<td class="col-amount" :class="amountClass(e.type)">
{{ amountSign(e.type) }}{{ won(e.amount) }}
</td>
<td class="col-act">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</td>
</tr>
</tbody>
</table>
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : ' 달의 내역이 없습니다.' }}</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
<form class="entry-form" @submit.prevent="submit">
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting">
<option value="EXPENSE">지출</option>
<option value="INCOME">수입</option>
<option value="TRANSFER">이체</option>
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
</select>
</label>
<label>{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
<select v-model="form.walletId" :disabled="submitting">
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</label>
<label v-if="form.type === 'TRANSFER'">입금 계좌
<select v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</label>
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 -->
<template v-if="isRepayment">
<label>대상(대출/카드)
<select v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in liabilityWallets" :key="w.id" :value="w.id">
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
</option>
</select>
</label>
<label>원금<input v-model.number="form.principal" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label>이자<input v-model.number="form.interest" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
</template>
<label v-if="form.type === 'INCOME' || form.type === 'EXPENSE'">분류
<div v-if="!addingCategory" class="cat-input">
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
</div>
<div v-else class="cat-input">
<input
v-model="newCategoryName" type="text"
:placeholder="form.type === 'EXPENSE' ? '새 지출 분류' : '새 수입 분류'"
:disabled="catSubmitting" @keyup.enter.prevent="confirmAddCategory"
/>
<button type="button" class="cat-add-btn primary" :disabled="catSubmitting" @click="confirmAddCategory">확인</button>
<button type="button" class="cat-add-btn" :disabled="catSubmitting" @click="cancelAddCategory">취소</button>
</div>
</label>
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment" class="tag-field">
<span class="tag-label">태그</span>
<div class="tag-badges">
<button
v-for="t in tagOptions"
:key="t.id"
type="button"
class="tag-badge"
:class="{ active: selectedTagIds.includes(t.id) }"
@click="toggleTag(t.id)"
>{{ t.name }}</button>
<span v-if="!tagOptions.length" class="tag-empty">등록된 태그가 없습니다 (태그 관리에서 추가)</span>
</div>
</div>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.account {
max-width: 760px;
margin: 0 auto;
}
.account-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
.filter-toggle {
font-size: 0.82rem;
padding: 0.35rem 0.7rem;
}
.filter-toggle.on {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.filter-badge {
margin-left: 0.25rem;
font-size: 0.6rem;
vertical-align: middle;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.filter-bar input,
.filter-bar select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
}
.filter-bar .f-keyword {
flex: 1;
min-width: 8rem;
}
.summary {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.card {
flex: 1;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.card .label {
font-size: 0.85rem;
opacity: 0.7;
}
.card .value {
font-size: 1.15rem;
font-weight: 700;
}
.card.income .value {
color: #2e7d32;
}
.card.expense .value {
color: #c0392b;
}
.entry-table {
width: 100%;
border-collapse: collapse;
}
.entry-table th,
.entry-table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
font-size: 0.92rem;
}
.col-date {
width: 60px;
}
.col-cat {
width: 120px;
white-space: nowrap;
}
.col-amount {
width: 120px;
text-align: right;
font-weight: 600;
white-space: nowrap;
}
.col-amount.income {
color: #2e7d32;
}
.col-amount.expense {
color: #c0392b;
}
.col-act {
width: 110px;
white-space: nowrap;
}
.col-act button {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.type-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.35rem;
}
.type-dot.income {
background: #2e7d32;
}
.type-dot.expense {
background: #c0392b;
}
.type-dot.transfer {
background: #888;
}
.col-amount.transfer {
color: var(--color-text);
opacity: 0.8;
}
.memo {
color: var(--color-text);
opacity: 0.85;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.entry-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.entry-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.entry-form input,
.entry-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.cat-input {
display: flex;
gap: 0.4rem;
}
.cat-input select,
.cat-input input {
flex: 1;
min-width: 0;
}
.cat-add-btn {
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
white-space: nowrap;
flex-shrink: 0;
}
.cat-add-btn.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.tag-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.tag-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag-badge {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.tag-badge.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 0.12);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.tag-empty {
font-size: 0.8rem;
opacity: 0.6;
}
.row-tag {
margin-left: 0.35rem;
font-size: 0.75rem;
color: hsla(160, 100%, 37%, 1);
}
.row-wallet {
margin-right: 0.35rem;
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
opacity: 0.85;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일: 내역 표를 2줄(분류·금액 / 날짜·메모·액션)로 ===== */
@media (max-width: 768px) {
.account {
max-width: 100%;
}
.summary {
gap: 0.5rem;
}
.card {
padding: 0.6rem 0.7rem;
}
.card .value {
font-size: 1rem;
}
.entry-table thead {
display: none;
}
.entry-table,
.entry-table tbody,
.entry-table tr,
.entry-table td {
display: block;
}
.entry-table tr {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.5rem;
row-gap: 0.15rem;
padding: 0.6rem 0.1rem;
}
.entry-table td {
border: 0;
padding: 0;
width: auto;
}
.entry-table .col-cat {
order: 0;
flex: 1;
white-space: normal;
font-size: 0.92rem;
}
.entry-table .col-amount {
order: 1;
width: auto;
font-size: 0.92rem;
}
.entry-table .col-date {
order: 2;
width: auto;
font-size: 0.76rem;
opacity: 0.65;
}
.entry-table .memo {
order: 3;
flex: 1;
font-size: 0.8rem;
}
.entry-table .col-act {
order: 4;
width: auto;
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.filter-bar .f-keyword {
flex-basis: 100%;
}
}
</style>
+659
View File
@@ -0,0 +1,659 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import InvestPortfolio from './InvestPortfolio.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const TABS = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'INVEST', label: '투자' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false)
const error = ref(null)
const activeType = ref('BANK')
const formOpen = ref(false)
const editId = ref(null)
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
const submitting = ref(false)
const formError = ref(null)
// 드롭다운(계좌별 내역)
const expandedId = ref(null)
const entriesByWallet = reactive({})
const loadingEntries = ref(false)
const filtered = computed(() => wallets.value.filter((w) => w.type === activeType.value))
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
async function toggleExpand(w) {
if (expandedId.value === w.id) {
expandedId.value = null
return
}
expandedId.value = w.id
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
loadingEntries.value = true
try {
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
} catch {
entriesByWallet[w.id] = []
} finally {
loadingEntries.value = false
}
}
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
function effectAmount(e, walletId) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e, w) {
const m = e.memo ? ` · ${e.memo}` : ''
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '초기 투입원금' : '대출 잔액(원금)'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() {
loading.value = true
error.value = null
try {
const [ws, nw] = await Promise.all([accountApi.wallets(), accountApi.netWorth()])
wallets.value = ws
networth.value = nw
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
type: activeType.value,
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// 부채는 음수로 저장 (갚을 금액)
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(w) {
if (!confirm(`'${w.name}' 을(를) 삭제할까요?`)) return
try {
await accountApi.removeWallet(w.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
onMounted(load)
</script>
<template>
<section class="wallet">
<header class="head">
<h1>계좌 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<!-- 순자산 요약 -->
<div class="networth">
<div class="nw-card">
<span class="label">총자산</span>
<span class="value asset">{{ won(networth.totalAssets) }}</span>
</div>
<div class="nw-card">
<span class="label">총부채</span>
<span class="value debt">{{ won(networth.totalLiabilities) }}</span>
</div>
<div class="nw-card">
<span class="label">순자산</span>
<span class="value">{{ won(networth.netWorth) }}</span>
</div>
</div>
<div class="tabs">
<button
v-for="t in TABS"
:key="t.key"
type="button"
:class="{ active: activeType === t.key }"
@click="activeType = t.key"
>{{ t.label }}</button>
</div>
<div class="toolbar">
<IconBtn icon="plus" title="추가" variant="primary" @click="openCreate" />
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="filtered.length" class="wallet-list">
<li v-for="w in filtered" :key="w.id" class="wallet-item">
<div class="wallet-row" @click="toggleExpand(w)">
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
<div class="info">
<div class="line1">
<span class="name">{{ w.name }}</span>
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div>
<span class="sub">
{{ w.issuer }}
<template v-if="w.type === 'BANK' && w.accountNumber"> · {{ w.accountNumber }}</template>
<template v-if="w.type === 'INVEST'"> · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}</template>
</span>
</div>
<div class="balance-wrap">
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
<div
v-if="w.type === 'INVEST' && w.valuationGain != null"
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
>
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</div>
</div>
<div class="actions" @click.stop>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div>
</div>
<!-- 드롭다운: 투자는 포트폴리오, 외는 계좌별 내역 -->
<div v-if="expandedId === w.id" class="entry-drop">
<InvestPortfolio v-if="w.type === 'INVEST'" :wallet-id="w.id" @changed="load" />
<template v-else>
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
<ul v-else-if="(entriesByWallet[w.id] || []).length" class="drop-list">
<li v-for="e in entriesByWallet[w.id]" :key="e.id" class="drop-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e, w) }}</span>
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
</span>
</li>
</ul>
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
</template>
</div>
</li>
</ul>
<p v-else-if="!loading" class="msg">등록된 항목이 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label>{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label v-if="form.type === 'CARD'">카드 종류
<select v-model="form.cardType" :disabled="submitting">
<option value="CREDIT">신용카드</option>
<option value="CHECK">체크카드</option>
</select>
</label>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<p v-if="form.type === 'INVEST'" class="form-hint">
증권계좌 입금은 은행투자 이체 기록하세요. 계좌를 펼치면 <b>종목·매수/매도·현재가</b> 관리할 있고, 평가금액·손익은 자동 계산됩니다.
</p>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.wallet {
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.networth {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.nw-card {
flex: 1;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.7rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.nw-card .label {
font-size: 0.82rem;
opacity: 0.7;
}
.nw-card .value {
font-size: 1.1rem;
font-weight: 700;
}
.nw-card .value.asset {
color: #2e7d32;
}
.nw-card .value.debt {
color: #c0392b;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.tabs button {
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.toolbar {
margin-bottom: 0.75rem;
}
.wallet-list {
list-style: none;
}
.wallet-item {
border-bottom: 1px solid var(--color-border);
}
.wallet-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
}
.chevron {
width: 1rem;
opacity: 0.6;
font-size: 0.8rem;
}
.entry-drop {
padding: 0.25rem 0 0.75rem 1.6rem;
}
.drop-msg {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem 0;
}
.drop-list {
list-style: none;
}
.drop-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
font-size: 0.85rem;
border-top: 1px dashed var(--color-border);
}
.d-date {
width: 3rem;
opacity: 0.7;
white-space: nowrap;
}
.d-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d-amount {
white-space: nowrap;
font-weight: 600;
}
.d-amount.pos {
color: #2e7d32;
}
.d-amount.neg {
color: #c0392b;
}
.info {
flex: 1;
min-width: 0;
}
.line1 {
display: flex;
align-items: center;
gap: 0.4rem;
}
.name {
font-weight: 600;
}
.badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
.sub {
font-size: 0.82rem;
opacity: 0.7;
}
.balance-wrap {
text-align: right;
white-space: nowrap;
}
.balance {
font-weight: 700;
white-space: nowrap;
}
.balance.neg {
color: #c0392b;
}
.gain {
font-size: 0.76rem;
font-weight: 600;
}
.gain.pos {
color: #2e7d32;
}
.gain.neg {
color: #c0392b;
}
.form-hint {
font-size: 0.76rem;
opacity: 0.65;
margin: -0.2rem 0 0.1rem;
}
.actions {
display: flex;
gap: 0.4rem;
}
.actions button {
padding: 0.2rem 0.55rem;
font-size: 0.82rem;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.wallet-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.wallet-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.wallet-form input,
.wallet-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.wallet {
max-width: 100%;
}
.networth {
gap: 0.5rem;
}
.nw-card {
padding: 0.5rem 0.6rem;
}
.nw-card .value {
font-size: 0.95rem;
}
.tabs button {
padding: 0.55rem 0.6rem;
font-size: 0.9rem;
}
/* 한 줄 유지: 이름(축소) · 잔액 · 아이콘 */
.wallet-row {
column-gap: 0.4rem;
}
.info {
flex: 1;
min-width: 0;
}
/* 이름은 한 줄(말줄임), sub(예수금·주식 등)는 자연 줄바꿈 — 손익과 겹치지 않게 */
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
word-break: break-all;
}
.wallet-row {
align-items: flex-start;
}
.balance {
font-size: 0.95rem;
}
.balance-wrap {
flex-shrink: 0;
padding-left: 0.3rem;
}
.actions {
flex-shrink: 0;
gap: 0.3rem;
}
}
</style>
+544
View File
@@ -0,0 +1,544 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const statuses = ref([])
const budgetsRaw = ref([])
const loading = ref(false)
const error = ref(null)
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
const submitting = ref(false)
const formError = ref(null)
// 분류(지출) 드롭다운
const categories = ref([])
async function loadCategories() {
try {
categories.value = await accountApi.categories()
} catch {
categories.value = []
}
}
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate()
}
function daysInYear(y) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
}
function baseDays(unit) {
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
}
// 고정 모드 자동 환산 미리보기 (현재 월/년 실제 일수 기준)
const fixedPreview = computed(() => {
if (!form.fixed) return null
const amount = Number(form.baseAmount) || 0
const daily = amount / baseDays(form.baseUnit)
return {
weekly: Math.round(daily * 7),
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
yearly: Math.round(daily * daysInYear(year.value)),
}
})
function ratio(s) {
if (!s.monthlyBudget) return 0
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
}
function barClass(s) {
const r = s.monthlyBudget ? s.spent / s.monthlyBudget : 0
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
}
async function load() {
loading.value = true
error.value = null
try {
const params = { year: year.value, month: month.value }
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
statuses.value = st
budgetsRaw.value = raw
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else month.value -= 1
load()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else month.value += 1
load()
}
function openCreate() {
editId.value = null
Object.assign(form, {
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(s) {
const b = budgetsRaw.value.find((x) => x.id === s.id)
if (!b) return
editId.value = b.id
Object.assign(form, {
category: b.category,
fixed: b.fixed,
baseUnit: b.baseUnit || 'DAY',
baseAmount: b.baseAmount,
daily: b.daily,
weekly: b.weekly,
monthly: b.monthly,
yearly: b.yearly,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.category.trim()) {
formError.value = '카테고리를 입력하세요.'
return
}
let payload
if (form.fixed) {
if (!form.baseAmount || form.baseAmount <= 0) {
formError.value = '기준 금액을 입력하세요.'
return
}
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
} else {
payload = {
category: form.category.trim(),
fixed: false,
daily: form.daily != null ? Number(form.daily) : null,
weekly: form.weekly != null ? Number(form.weekly) : null,
monthly: form.monthly != null ? Number(form.monthly) : null,
yearly: form.yearly != null ? Number(form.yearly) : null,
}
}
submitting.value = true
try {
if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(s) {
if (!confirm(`'${s.category}' 예산을 삭제할까요?`)) return
try {
await accountApi.removeBudget(s.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(() => {
load()
loadCategories()
})
</script>
<template>
<section class="budget">
<header class="head">
<h1>예산 설정</h1>
<div class="head-actions">
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
</div>
</header>
<div class="month-nav">
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="statuses.length" class="status-list">
<li v-for="s in statuses" :key="s.id" class="status-row">
<div class="srow-top">
<span class="cat">{{ s.category }}<span v-if="s.fixed" class="fixed-badge">고정</span></span>
<span class="amounts">
<span :class="s.remaining < 0 ? 'neg' : ''">{{ won(s.spent) }}</span>
/ {{ won(s.monthlyBudget) }}
</span>
</div>
<div class="bar">
<div class="bar-fill" :class="barClass(s)" :style="{ width: Math.min(ratio(s), 100) + '%' }"></div>
</div>
<div class="srow-bottom">
<span class="ratio" :class="barClass(s)">{{ ratio(s) }}%</span>
<span class="remain">
{{ s.remaining >= 0 ? `잔여 ${won(s.remaining)}` : `초과 ${won(-s.remaining)}` }}
</span>
<span class="acts">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(s)" />
</span>
</div>
</li>
</ul>
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
<form class="budget-form" @submit.prevent="submit">
<label>카테고리
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
</label>
<div class="mode">
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
</div>
<!-- 고정 -->
<template v-if="form.fixed">
<div class="fixed-base">
<select v-model="form.baseUnit" :disabled="submitting">
<option value="DAY"></option>
<option value="WEEK"></option>
<option value="MONTH"></option>
</select>
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
</div>
<p v-if="fixedPreview" class="preview">
환산 {{ won(fixedPreview.weekly) }} · {{ won(fixedPreview.monthly) }} · {{ won(fixedPreview.yearly) }}
<span class="note">(이번 {{ daysInMonth(year, month) }} / 올해 {{ daysInYear(year) }} 기준)</span>
</p>
</template>
<!-- 비고정 -->
<template v-else>
<label> 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.budget {
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
.status-list {
list-style: none;
}
.status-row {
padding: 0.85rem 0;
border-bottom: 1px solid var(--color-border);
}
.srow-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.4rem;
}
.cat {
font-weight: 600;
}
.fixed-badge {
margin-left: 0.4rem;
font-size: 0.7rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.3rem;
opacity: 0.8;
}
.amounts {
font-size: 0.9rem;
}
.amounts .neg {
color: #c0392b;
font-weight: 600;
}
.bar {
height: 8px;
background: var(--color-background-mute);
border-radius: 999px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
.bar-fill.ok {
background: #2e7d32;
}
.bar-fill.warn {
background: #e67e22;
}
.bar-fill.over {
background: #c0392b;
}
.srow-bottom {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.35rem;
font-size: 0.82rem;
}
.ratio.ok {
color: #2e7d32;
}
.ratio.warn {
color: #e67e22;
}
.ratio.over {
color: #c0392b;
font-weight: 700;
}
.remain {
opacity: 0.8;
}
.acts {
margin-left: auto;
display: flex;
gap: 0.35rem;
}
.acts button {
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.budget-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.budget-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.budget-form input,
.budget-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.mode {
display: flex;
gap: 1rem;
font-size: 0.85rem;
padding: 0.25rem 0;
}
.radio {
flex-direction: row !important;
align-items: center;
gap: 0.3rem !important;
cursor: pointer;
}
.fixed-base {
display: flex;
gap: 0.5rem;
}
.fixed-base select {
width: 5rem;
}
.fixed-base input {
flex: 1;
}
.preview {
font-size: 0.82rem;
background: var(--color-background-soft);
border-radius: 4px;
padding: 0.5rem 0.6rem;
}
.note {
font-size: 0.78rem;
opacity: 0.6;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const categories = ref([])
const loading = ref(false)
const error = ref(null)
const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('')
const filtered = computed(() => categories.value.filter((c) => c.type === activeType.value))
async function load() {
loading.value = true
error.value = null
try {
categories.value = await accountApi.categories()
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function addCategory() {
const name = newName.value.trim()
if (!name) return
try {
await accountApi.createCategory({ type: activeType.value, name })
newName.value = ''
await load()
} catch (e) {
alert(e.response?.data?.message || '추가에 실패했습니다.')
}
}
async function saveCategory(c) {
const name = (c.name || '').trim()
if (!name) return
try {
await accountApi.updateCategory(c.id, { type: c.type, name })
await load()
} catch (e) {
alert(e.response?.data?.message || '수정에 실패했습니다.')
}
}
async function removeCategory(c) {
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return
try {
await accountApi.removeCategory(c.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
async function importExisting() {
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return
try {
categories.value = await accountApi.importCategories()
} catch (e) {
alert(e.response?.data?.message || '불러오기에 실패했습니다.')
}
}
onMounted(load)
</script>
<template>
<section class="cat">
<header class="head">
<h1>분류 관리</h1>
<div class="head-actions">
<button type="button" class="text-btn" @click="importExisting">기존 분류 불러오기</button>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</div>
</header>
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. 분류 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
<div class="tabs">
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
<button type="button" :class="{ active: activeType === 'INCOME' }" @click="activeType = 'INCOME'">수입 분류</button>
</div>
<form class="new-cat" @submit.prevent="addCategory">
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="filtered.length" class="cat-list">
<li v-for="c in filtered" :key="c.id" class="cat-row">
<input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
<IconBtn icon="check" title="저장" @click="saveCategory(c)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" />
</li>
</ul>
<p v-else-if="!loading" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p>
</section>
</template>
<style scoped>
.cat {
max-width: 560px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
h1 {
font-size: 1.5rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
button {
padding: 0.45rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.tabs button {
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.new-cat {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.new-cat input {
flex: 1;
}
.cat-list {
list-style: none;
}
.cat-row {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.cat-name {
flex: 1;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
</style>
+588
View File
@@ -0,0 +1,588 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const props = defineProps({ walletId: { type: [Number, String], required: true } })
const emit = defineEmits(['changed'])
const holdings = ref([])
const loading = ref(false)
// 종목 추가/수정 모달
const holdingModal = ref(false)
const holdingEditId = ref(null)
const hForm = reactive({ name: '', ticker: '', currentPrice: null })
const hSubmitting = ref(false)
const hError = ref(null)
// 매매 모달
const tradeModal = ref(false)
const tradeHolding = ref(null)
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, fee: null })
const tSubmitting = ref(false)
const tError = ref(null)
// 매매이력 드롭다운
const openTradesId = ref(null)
const tradesByHolding = reactive({})
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function tDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
async function load() {
loading.value = true
try {
holdings.value = await accountApi.investHoldings(props.walletId)
} catch {
holdings.value = []
} finally {
loading.value = false
}
}
/* ===== 종목 ===== */
function openAddHolding() {
holdingEditId.value = null
Object.assign(hForm, { name: '', ticker: '', currentPrice: null })
hError.value = null
holdingModal.value = true
}
function openEditHolding(h) {
holdingEditId.value = h.id
Object.assign(hForm, { name: h.name, ticker: h.ticker || '', currentPrice: h.currentPrice ?? null })
hError.value = null
holdingModal.value = true
}
async function submitHolding() {
hError.value = null
if (!hForm.name.trim()) {
hError.value = '종목명을 입력하세요.'
return
}
hSubmitting.value = true
const payload = {
walletId: Number(props.walletId),
name: hForm.name.trim(),
ticker: hForm.ticker || null,
currentPrice: hForm.currentPrice !== '' && hForm.currentPrice != null ? Number(hForm.currentPrice) : null,
}
try {
if (holdingEditId.value) await accountApi.updateHolding(holdingEditId.value, payload)
else await accountApi.createHolding(payload)
holdingModal.value = false
await load()
emit('changed')
} catch (e) {
hError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
hSubmitting.value = false
}
}
async function removeHolding(h) {
if (!confirm(`'${h.name}' 종목과 매매이력을 모두 삭제할까요?`)) return
try {
await accountApi.removeHolding(h.id)
await load()
emit('changed')
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
/* ===== 매매 ===== */
function openTrade(h, type) {
tradeHolding.value = h
Object.assign(tForm, { tradeType: type, tradeDate: todayStr(), quantity: null, price: h.currentPrice ?? null, fee: null })
tError.value = null
tradeModal.value = true
}
async function submitTrade() {
tError.value = null
if (!tForm.quantity || tForm.quantity <= 0) {
tError.value = '수량을 입력하세요.'
return
}
if (tForm.price == null || tForm.price < 0) {
tError.value = '단가를 입력하세요.'
return
}
tSubmitting.value = true
const payload = {
tradeType: tForm.tradeType,
tradeDate: tForm.tradeDate,
quantity: Number(tForm.quantity),
price: Number(tForm.price),
fee: tForm.fee ? Number(tForm.fee) : 0,
}
try {
await accountApi.addTrade(tradeHolding.value.id, payload)
tradeModal.value = false
if (openTradesId.value === tradeHolding.value.id) await loadTrades(tradeHolding.value.id)
await load()
emit('changed')
} catch (e) {
tError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
tSubmitting.value = false
}
}
async function loadTrades(holdingId) {
try {
tradesByHolding[holdingId] = await accountApi.holdingTrades(holdingId)
} catch {
tradesByHolding[holdingId] = []
}
}
async function toggleTrades(h) {
if (openTradesId.value === h.id) {
openTradesId.value = null
return
}
openTradesId.value = h.id
await loadTrades(h.id)
}
async function removeTrade(t, holdingId) {
if (!confirm('이 매매 내역을 삭제할까요?')) return
try {
await accountApi.removeTrade(t.id)
await loadTrades(holdingId)
await load()
emit('changed')
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(load)
</script>
<template>
<div class="portfolio">
<div class="pf-head">
<span class="pf-title">보유 종목</span>
<IconBtn icon="plus" title="종목 추가" variant="primary" size="sm" @click="openAddHolding" />
</div>
<p v-if="loading" class="pf-msg">불러오는 중...</p>
<p v-else-if="!holdings.length" class="pf-msg">보유 종목이 없습니다. 종목을 추가하고 매수를 기록하세요.</p>
<ul v-else class="hold-list">
<li v-for="h in holdings" :key="h.id" class="hold-item">
<div class="hold-row">
<div class="h-info" @click="toggleTrades(h)">
<span class="chev">{{ openTradesId === h.id ? '▾' : '▸' }}</span>
<div>
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
<div class="h-sub">
{{ won(h.quantity) }} · 평단 {{ won(h.avgPrice) }}
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
<span v-else class="noprice"> · 현재가 미입력</span>
</div>
</div>
</div>
<div class="h-val">
<div class="h-eval">{{ won(h.evalValue) }}</div>
<div class="h-gain" :class="h.evalGain < 0 ? 'neg' : 'pos'">
{{ h.evalGain >= 0 ? '+' : '' }}{{ won(h.evalGain) }}<span v-if="h.returnPct != null"> ({{ h.returnPct }}%)</span>
</div>
<div v-if="h.realizedPL" class="h-realized">실현 {{ h.realizedPL >= 0 ? '+' : '' }}{{ won(h.realizedPL) }}</div>
</div>
<div class="h-actions">
<IconBtn icon="plus" title="매수" variant="buy" size="sm" @click="openTrade(h, 'BUY')" />
<IconBtn icon="minus" title="매도" variant="sell" size="sm" :disabled="h.quantity <= 0" @click="openTrade(h, 'SELL')" />
<IconBtn icon="edit" title="현재가 수정" size="sm" @click="openEditHolding(h)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeHolding(h)" />
</div>
</div>
<!-- 매매이력 -->
<div v-if="openTradesId === h.id" class="trade-drop">
<ul v-if="(tradesByHolding[h.id] || []).length" class="trade-list">
<li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row">
<span class="t-date">{{ tDate(t.tradeDate) }}</span>
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
<span class="t-qty">{{ won(t.quantity) }} × {{ won(t.price) }}</span>
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
<span class="t-amt">{{ won(t.amount) }}</span>
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
</li>
</ul>
<p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
</div>
</li>
</ul>
<!-- 종목 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="holdingModal = false">×</button>
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
<form class="pf-form" @submit.prevent="submitHolding">
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="(선택) 예: 005930" :disabled="hSubmitting" /></label>
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(수동 갱신)" :disabled="hSubmitting" /></label>
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
<div class="pf-buttons">
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
<!-- 매매 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="tradeModal = false">×</button>
<h2>{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}</h2>
<form class="pf-form" @submit.prevent="submitTrade">
<label>구분
<select v-model="tForm.tradeType" :disabled="tSubmitting">
<option value="BUY">매수</option>
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
</select>
</label>
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
<label>수량()<input v-model.number="tForm.quantity" type="number" min="1" :disabled="tSubmitting" /></label>
<label>단가()<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" /></label>
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ won(tradeHolding?.quantity) }} · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
<p v-if="tError" class="pf-msg err">{{ tError }}</p>
<div class="pf-buttons">
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
<IconBtn icon="save" title="등록" variant="primary" type="submit" :disabled="tSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.portfolio {
padding: 0.25rem 0 0.5rem;
}
.pf-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.pf-title {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.8;
}
.pf-add {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.pf-msg {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem 0;
}
.pf-msg.sm {
padding: 0.3rem 0;
}
.pf-msg.err {
color: #c0392b;
opacity: 1;
}
.hold-list {
list-style: none;
}
.hold-item {
border-top: 1px dashed var(--color-border);
}
.hold-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
flex-wrap: wrap;
}
.h-info {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 9rem;
cursor: pointer;
}
.chev {
opacity: 0.5;
font-size: 0.75rem;
}
.h-name {
font-weight: 600;
font-size: 0.9rem;
}
.h-ticker {
margin-left: 0.35rem;
font-size: 0.72rem;
opacity: 0.6;
}
.h-sub {
font-size: 0.76rem;
opacity: 0.7;
}
.noprice {
color: #e67e22;
}
.h-val {
text-align: right;
white-space: nowrap;
}
.h-eval {
font-weight: 700;
font-size: 0.9rem;
}
.h-gain {
font-size: 0.76rem;
font-weight: 600;
}
.h-gain.pos {
color: #2e7d32;
}
.h-gain.neg {
color: #c0392b;
}
.h-realized {
font-size: 0.7rem;
opacity: 0.7;
}
.h-actions {
display: flex;
gap: 0.25rem;
}
.h-actions button {
padding: 0.18rem 0.45rem;
font-size: 0.76rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.h-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.h-actions .buy {
border-color: #2e7d32;
color: #2e7d32;
}
.h-actions .sell {
border-color: #c0392b;
color: #c0392b;
}
.h-actions .danger {
border-color: #c0392b;
color: #c0392b;
}
.trade-drop {
padding: 0.1rem 0 0.5rem 1.4rem;
}
.trade-list {
list-style: none;
}
.trade-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.78rem;
}
.t-date {
opacity: 0.7;
width: 3rem;
}
.t-type {
font-weight: 600;
}
.t-type.buy {
color: #2e7d32;
}
.t-type.sell {
color: #c0392b;
}
.t-qty {
flex: 1;
}
.t-fee {
opacity: 0.6;
font-size: 0.72rem;
}
.t-amt {
font-weight: 600;
}
.t-del {
border: 0;
background: transparent;
color: #c0392b;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
/* 모달 (계좌 관리와 동일 톤) */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
text-align: center;
}
.pf-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.pf-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.pf-form input,
.pf-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.pf-hint {
font-size: 0.78rem;
opacity: 0.7;
}
.pf-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.pf-buttons button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.pf-buttons .primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
@media (max-width: 768px) {
.hold-row {
column-gap: 0.4rem;
}
.h-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.trade-drop {
padding-left: 0.8rem;
}
.trade-row {
flex-wrap: wrap;
column-gap: 0.4rem;
row-gap: 0.1rem;
}
.t-date,
.t-type,
.t-qty,
.t-fee,
.t-amt {
white-space: nowrap;
}
.t-date {
order: 0;
width: auto;
}
.t-type {
order: 1;
}
.t-amt {
order: 2;
margin-left: auto;
}
.t-del {
order: 3;
}
.t-qty {
order: 4;
flex: 0 1 auto;
}
.t-fee {
order: 5;
}
}
</style>
+467
View File
@@ -0,0 +1,467 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const recurrings = ref([])
const wallets = ref([])
const categories = ref([])
const loading = ref(false)
const error = ref(null)
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({
title: '',
type: 'EXPENSE',
amount: null,
category: '',
memo: '',
walletId: '',
toWalletId: '',
frequency: 'MONTHLY',
dayOfMonth: 1,
dayOfWeek: 1,
monthOfYear: 1,
startDate: '',
endDate: '',
active: true,
})
const submitting = ref(false)
const formError = ref(null)
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === form.type).map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function typeLabel(t) {
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
}
function freqLabel(r) {
if (r.frequency === 'DAILY') return '매일'
if (r.frequency === 'WEEKLY') return `매주 ${DOW[r.dayOfWeek] || ''}요일`
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}`
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function load() {
loading.value = true
error.value = null
try {
const [recs, ws, cats] = await Promise.all([
accountApi.recurrings(),
accountApi.wallets(),
accountApi.categories(),
])
recurrings.value = recs
wallets.value = ws
categories.value = cats
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function runNow() {
try {
const res = await accountApi.runRecurrings()
alert(`${res.generated}건의 정기 거래가 반영되었습니다.`)
await load()
} catch (e) {
alert(e.response?.data?.message || '반영에 실패했습니다.')
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
walletId: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
})
formError.value = null
formOpen.value = true
}
function openEdit(r) {
editId.value = r.id
Object.assign(form, {
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
walletId: r.walletId || '', toWalletId: r.toWalletId || '', frequency: r.frequency,
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.title.trim()) {
formError.value = '이름을 입력하세요.'
return
}
if (!form.amount || form.amount <= 0) {
formError.value = '금액을 입력하세요.'
return
}
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
return
}
if (!form.startDate) {
formError.value = '시작일을 입력하세요.'
return
}
submitting.value = true
const payload = {
title: form.title.trim(),
type: form.type,
amount: Number(form.amount),
category: form.type === 'TRANSFER' ? null : form.category || null,
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
frequency: form.frequency,
dayOfMonth: ['MONTHLY', 'YEARLY'].includes(form.frequency) ? Number(form.dayOfMonth) : null,
dayOfWeek: form.frequency === 'WEEKLY' ? Number(form.dayOfWeek) : null,
monthOfYear: form.frequency === 'YEARLY' ? Number(form.monthOfYear) : null,
startDate: form.startDate,
endDate: form.endDate || null,
active: form.active,
}
try {
if (editId.value) await accountApi.updateRecurring(editId.value, payload)
else await accountApi.createRecurring(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(r) {
if (!confirm(`'${r.title}' 정기 거래를 삭제할까요? (이미 생성된 내역은 유지됩니다)`)) return
try {
await accountApi.removeRecurring(r.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(load)
</script>
<template>
<section class="recur">
<header class="head">
<h1>정기 거래</h1>
<div class="head-actions">
<IconBtn icon="refresh" title="지금 반영" @click="runNow" />
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
<IconBtn icon="plus" title="정기 거래 추가" variant="primary" @click="openCreate" />
</div>
</header>
<p class="hint">등록한 주기에 맞춰 가계부 진입 자동으로 내역이 생성됩니다. (중복 없이)</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="recurrings.length" class="recur-list">
<li v-for="r in recurrings" :key="r.id" class="recur-row" :class="{ inactive: !r.active }">
<div class="info">
<div class="line1">
<span class="title">{{ r.title }}</span>
<span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span>
<span v-if="!r.active" class="off">중지</span>
</div>
<span class="sub">
{{ freqLabel(r) }} · {{ won(r.amount) }}
<template v-if="r.category"> · {{ r.category }}</template>
<template v-if="r.nextDate"> · 다음 {{ r.nextDate }}</template>
</span>
</div>
<div class="actions">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(r)" />
</div>
</li>
</ul>
<p v-else-if="!loading" class="msg">등록된 정기 거래가 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>정기 거래 {{ editId ? '수정' : '추가' }}</h2>
<form class="recur-form" @submit.prevent="submit">
<label>이름<input v-model="form.title" type="text" placeholder="예: 월세, 급여" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting">
<option value="EXPENSE">지출</option>
<option value="INCOME">수입</option>
<option value="TRANSFER">이체</option>
</select>
</label>
<label>금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label>{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}
<select v-model="form.walletId" :disabled="submitting">
<option value="">(선택 )</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
</select>
</label>
<label v-if="form.type === 'TRANSFER'">입금 계좌
<select v-model="form.toWalletId" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
</select>
</label>
<label v-if="form.type !== 'TRANSFER'">분류
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
</label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label>주기
<select v-model="form.frequency" :disabled="submitting">
<option value="DAILY">매일</option>
<option value="WEEKLY">매주</option>
<option value="MONTHLY">매월</option>
<option value="YEARLY">매년</option>
</select>
</label>
<label v-if="form.frequency === 'WEEKLY'">요일
<select v-model.number="form.dayOfWeek" :disabled="submitting">
<option v-for="d in 7" :key="d" :value="d">{{ DOW[d] }}</option>
</select>
</label>
<label v-if="form.frequency === 'YEARLY'">
<input v-model.number="form.monthOfYear" type="number" min="1" max="12" :disabled="submitting" />
</label>
<label v-if="['MONTHLY', 'YEARLY'].includes(form.frequency)">(날짜)
<input v-model.number="form.dayOfMonth" type="number" min="1" max="31" :disabled="submitting" />
</label>
<label>시작일<input v-model="form.startDate" type="date" :disabled="submitting" /></label>
<label>종료일(선택)<input v-model="form.endDate" type="date" :disabled="submitting" /></label>
<label class="row"><input type="checkbox" v-model="form.active" :disabled="submitting" /> 활성</label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.recur {
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
h1 {
font-size: 1.5rem;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
button {
padding: 0.45rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.recur-list {
list-style: none;
}
.recur-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0;
border-bottom: 1px solid var(--color-border);
}
.recur-row.inactive {
opacity: 0.55;
}
.line1 {
display: flex;
align-items: center;
gap: 0.4rem;
}
.title {
font-weight: 600;
}
.type-badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
.type-badge.income {
color: #2e7d32;
border-color: #2e7d32;
}
.type-badge.expense {
color: #c0392b;
border-color: #c0392b;
}
.off {
font-size: 0.72rem;
opacity: 0.6;
}
.sub {
font-size: 0.83rem;
opacity: 0.75;
}
.actions {
display: flex;
gap: 0.4rem;
}
.actions button {
padding: 0.2rem 0.55rem;
font-size: 0.82rem;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
max-height: 90vh;
overflow-y: auto;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.recur-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.recur-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.recur-form label.row {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.recur-form input,
.recur-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.recur-form label.row input {
padding: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+266
View File
@@ -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>
+321
View File
@@ -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>
+473
View File
@@ -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>
+202
View File
@@ -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>