diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
new file mode 100644
index 0000000..8555995
--- /dev/null
+++ b/docs/FRONTEND.md
@@ -0,0 +1,85 @@
+# Slim Budget — 프론트엔드 (sb_pt)
+
+Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
+
+## 기술 스택
+- **Vue 3** (`
-
-
-
+
+
diff --git a/src/api/accountApi.js b/src/api/accountApi.js
new file mode 100644
index 0000000..8588f8b
--- /dev/null
+++ b/src/api/accountApi.js
@@ -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}`)
+ },
+}
diff --git a/src/api/adminApi.js b/src/api/adminApi.js
new file mode 100644
index 0000000..3179d52
--- /dev/null
+++ b/src/api/adminApi.js
@@ -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 })
+ },
+}
diff --git a/src/api/authApi.js b/src/api/authApi.js
new file mode 100644
index 0000000..c150de8
--- /dev/null
+++ b/src/api/authApi.js
@@ -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')
+ },
+}
diff --git a/src/api/boardApi.js b/src/api/boardApi.js
new file mode 100644
index 0000000..8efca36
--- /dev/null
+++ b/src/api/boardApi.js
@@ -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`)
+ },
+}
diff --git a/src/api/http.js b/src/api/http.js
index 0fd9a48..4bffdc6 100644
--- a/src/api/http.js
+++ b/src/api/http.js
@@ -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)
},
diff --git a/src/assets/main.css b/src/assets/main.css
index 36fb845..752207e 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -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;
}
diff --git a/src/components/LoginModal.vue b/src/components/LoginModal.vue
new file mode 100644
index 0000000..8631235
--- /dev/null
+++ b/src/components/LoginModal.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
로그인
+
+
+
+
{{ error }}
+
+
+
+
+ 계정이 없으신가요?
+ 회원가입
+
+
+
+
+
+
+
+
diff --git a/src/components/SignupModal.vue b/src/components/SignupModal.vue
new file mode 100644
index 0000000..4bfe3eb
--- /dev/null
+++ b/src/components/SignupModal.vue
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
회원가입
+
+
+
+
{{ error }}
+
+
+ 이미 계정이 있으신가요?
+ 로그인
+
+
+
+
+
+
+
+
diff --git a/src/components/editor/MarkdownEditor.vue b/src/components/editor/MarkdownEditor.vue
new file mode 100644
index 0000000..7e06967
--- /dev/null
+++ b/src/components/editor/MarkdownEditor.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
diff --git a/src/components/editor/MarkdownViewer.vue b/src/components/editor/MarkdownViewer.vue
new file mode 100644
index 0000000..a409e7d
--- /dev/null
+++ b/src/components/editor/MarkdownViewer.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
diff --git a/src/components/layout/AppFooter.vue b/src/components/layout/AppFooter.vue
new file mode 100644
index 0000000..f3e6172
--- /dev/null
+++ b/src/components/layout/AppFooter.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue
new file mode 100644
index 0000000..4b98762
--- /dev/null
+++ b/src/components/layout/AppHeader.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue
new file mode 100644
index 0000000..f686991
--- /dev/null
+++ b/src/components/layout/AppSidebar.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/IconBtn.vue b/src/components/ui/IconBtn.vue
new file mode 100644
index 0000000..a0cfe55
--- /dev/null
+++ b/src/components/ui/IconBtn.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
diff --git a/src/router/index.js b/src/router/index.js
index a803277..ad8da0a 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -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
diff --git a/src/stores/auth.js b/src/stores/auth.js
new file mode 100644
index 0000000..28d289f
--- /dev/null
+++ b/src/stores/auth.js
@@ -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
+ }
+}
diff --git a/src/stores/ui.js b/src/stores/ui.js
new file mode 100644
index 0000000..50b747a
--- /dev/null
+++ b/src/stores/ui.js
@@ -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,
+ }
+})
diff --git a/src/utils/datetime.js b/src/utils/datetime.js
new file mode 100644
index 0000000..b9d8aa2
--- /dev/null
+++ b/src/utils/datetime.js
@@ -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}`
+}
diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue
deleted file mode 100644
index 756ad2a..0000000
--- a/src/views/AboutView.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
This is an about page
-
-
-
-
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index 6bb706f..c79c554 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -1,9 +1,31 @@
-
-
-
+
+
+
diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue
index 2c6f365..da940c9 100644
--- a/src/views/UsersView.vue
+++ b/src/views/UsersView.vue
@@ -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) {
{{ formError }}
-
+
불러오는 중...
총 {{ users.length }}명
@@ -88,7 +87,7 @@ function formatDate(value) {
{{ user.email }} |
{{ formatDate(user.createdAt) }} |
-
+
|
@@ -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;
+ }
+}
diff --git a/src/views/account/AccountDashboardView.vue b/src/views/account/AccountDashboardView.vue
new file mode 100644
index 0000000..612efd7
--- /dev/null
+++ b/src/views/account/AccountDashboardView.vue
@@ -0,0 +1,862 @@
+
+
+
+
+
+
+
+
+ {{ periodLabel }}
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+
+
+
예산 대비 지출
+
+
+
+
지출 {{ won(spentTotal) }}
+
예산 {{ won(budgetTotal) }}
+
+ {{ budgetTotal - spentTotal >= 0 ? '잔여' : '초과' }} {{ won(Math.abs(budgetTotal - spentTotal)) }}
+
+
+
+
설정된 예산이 없습니다.
+
+
+
+
수입 대비 지출
+
+
수입{{ won(monthIncome) }}
+
지출{{ won(monthExpense) }}
+
+
수지{{ won(monthIncome - monthExpense) }}
+
+
+
+
+
+
+
+
분류별 {{ catType === 'EXPENSE' ? '지출' : '수입' }}
+
+
+
+
+
+
+
+
+ -
+
+ {{ s.category }}
+ {{ s.pct }}%
+ {{ won(s.total) }}
+
+
+
+
{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.
+
+
+
+
+
+
기간별 예산 대비 지출
+
+
+
+
+
+
+
+
+ 예산 지출
+
+
+
데이터가 없습니다.
+
+
+
+
+
순자산 추이 최근 12개월
+
+
+
+
+
+ {{ p.label }}
+
+
+
+
데이터가 없습니다.
+
+
+
+
+
+
월별 총액
+
+
+ {{ tableYear }}년
+
+
+
+
+
+ | 월 | 수입 | 지출 | 수지 |
+
+
+
+ | {{ r.month }}월 |
+ {{ won(r.income) }} |
+ {{ won(r.expense) }} |
+ {{ won(r.net) }} |
+
+
+
+
+
+
+
+
+
diff --git a/src/views/account/AccountTagView.vue b/src/views/account/AccountTagView.vue
new file mode 100644
index 0000000..e13b57f
--- /dev/null
+++ b/src/views/account/AccountTagView.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+ 내가 등록한 태그는 나의 가계부 내역에서만 사용됩니다.
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+ 등록된 태그가 없습니다.
+
+
+
+
diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue
new file mode 100644
index 0000000..7f47d9f
--- /dev/null
+++ b/src/views/account/AccountView.vue
@@ -0,0 +1,898 @@
+
+
+
+
+
+
+
+
+ {{ periodLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 수입
+ +{{ won(summary.totalIncome) }}
+
+
+ 지출
+ -{{ won(summary.totalExpense) }}
+
+
+ 잔액
+ {{ won(summary.balance) }}
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+
+ | 날짜 |
+ 분류 |
+ 메모 |
+ 금액 |
+ |
+
+
+
+
+ | {{ formatDate(e.entryDate) }} |
+
+
+ 이체
+ {{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}
+ |
+
+ {{ e.walletName }} → {{ e.toWalletName }}
+ {{ e.walletName }}
+ {{ e.memo }}
+ {{ t }}
+ |
+
+ {{ amountSign(e.type) }}{{ won(e.amount) }}
+ |
+
+
+
+ |
+
+
+
+ {{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}
+
+
+
+
+
+
+
+
{{ editId ? '내역 수정' : '내역 추가' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/account/AccountWalletView.vue b/src/views/account/AccountWalletView.vue
new file mode 100644
index 0000000..62121d1
--- /dev/null
+++ b/src/views/account/AccountWalletView.vue
@@ -0,0 +1,659 @@
+
+
+
+
+
+
+
+
+
+ 총자산
+ {{ won(networth.totalAssets) }}
+
+
+ 총부채
+ {{ won(networth.totalLiabilities) }}
+
+
+ 순자산
+ {{ won(networth.netWorth) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+ -
+
+
{{ expandedId === w.id ? '▾' : '▸' }}
+
+
+ {{ w.name }}
+ {{ cardTypeLabel(w.cardType) }}
+
+
+ {{ w.issuer }}
+ · {{ w.accountNumber }}
+ · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}
+
+
+
+
{{ won(w.balance) }}
+
+ {{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }} ({{ returnPct(w) }}%)
+
+
+
+
+
+
+
+
+
+
+
+
+ 불러오는 중...
+
+ -
+ {{ entryDate(e.entryDate) }}
+ {{ entryDesc(e, w) }}
+
+ {{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
+
+
+
+ 연관 내역이 없습니다.
+
+
+
+
+ 등록된 항목이 없습니다.
+
+
+
+
+
+
+
+
{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/account/BudgetView.vue b/src/views/account/BudgetView.vue
new file mode 100644
index 0000000..7c93376
--- /dev/null
+++ b/src/views/account/BudgetView.vue
@@ -0,0 +1,544 @@
+
+
+
+
+
+
+
+
+ {{ periodLabel }}
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+ 설정된 예산이 없습니다. "예산 추가"로 시작하세요.
+
+
+
+
+
+
+
+
예산 {{ editId ? '수정' : '추가' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/account/CategoryView.vue b/src/views/account/CategoryView.vue
new file mode 100644
index 0000000..12fe384
--- /dev/null
+++ b/src/views/account/CategoryView.vue
@@ -0,0 +1,197 @@
+
+
+
+
+
+ 분류 관리
+
+
+
+
+
+ 내역·예산에서 선택할 분류를 관리합니다. 분류 이름을 바꾸면 기존 내역·예산에도 반영됩니다.
+
+
+
+
+
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+ 등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
+
+
+
+
+
diff --git a/src/views/account/InvestPortfolio.vue b/src/views/account/InvestPortfolio.vue
new file mode 100644
index 0000000..9adfcae
--- /dev/null
+++ b/src/views/account/InvestPortfolio.vue
@@ -0,0 +1,588 @@
+
+
+
+
+
+ 보유 종목
+
+
+
+
불러오는 중...
+
보유 종목이 없습니다. 종목을 추가하고 매수를 기록하세요.
+
+
+ -
+
+
+
{{ openTradesId === h.id ? '▾' : '▸' }}
+
+
{{ h.name }}{{ h.ticker }}
+
+ {{ won(h.quantity) }}주 · 평단 {{ won(h.avgPrice) }}
+ · 현재가 {{ won(h.currentPrice) }}
+ · 현재가 미입력
+
+
+
+
+
{{ won(h.evalValue) }}
+
+ {{ h.evalGain >= 0 ? '+' : '' }}{{ won(h.evalGain) }} ({{ h.returnPct }}%)
+
+
실현 {{ h.realizedPL >= 0 ? '+' : '' }}{{ won(h.realizedPL) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ {{ tDate(t.tradeDate) }}
+ {{ t.tradeType === 'SELL' ? '매도' : '매수' }}
+ {{ won(t.quantity) }}주 × {{ won(t.price) }}
+ 수수료 {{ won(t.fee) }}
+ {{ won(t.amount) }}
+
+
+
+
매매 내역이 없습니다.
+
+
+
+
+
+
+
+
+
+
+
종목 {{ holdingEditId ? '수정' : '추가' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/account/RecurringView.vue b/src/views/account/RecurringView.vue
new file mode 100644
index 0000000..91b3424
--- /dev/null
+++ b/src/views/account/RecurringView.vue
@@ -0,0 +1,467 @@
+
+
+
+
+
+ 등록한 주기에 맞춰 가계부 진입 시 자동으로 내역이 생성됩니다. (중복 없이)
+
+ {{ error }}
+ 불러오는 중...
+
+
+ -
+
+
+ {{ r.title }}
+ {{ typeLabel(r.type) }}
+ 중지
+
+
+ {{ freqLabel(r) }} · {{ won(r.amount) }}
+ · {{ r.category }}
+ · 다음 {{ r.nextDate }}
+
+
+
+
+
+
+
+
+ 등록된 정기 거래가 없습니다.
+
+
+
+
+
+
+
+
정기 거래 {{ editId ? '수정' : '추가' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/admin/TagAdminView.vue b/src/views/admin/TagAdminView.vue
new file mode 100644
index 0000000..bab6c6d
--- /dev/null
+++ b/src/views/admin/TagAdminView.vue
@@ -0,0 +1,266 @@
+
+
+
+
+ 태그 관리
+
+
+
+
+
+ 선택 시 글쓰기에서 해당 카테고리의 태그만 사용할 수 있습니다.
+
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ 태그 없음
+
+
+
+
+
+ 카테고리를 추가해 태그 관리를 시작하세요.
+
+
+
+
diff --git a/src/views/board/BoardDetailView.vue b/src/views/board/BoardDetailView.vue
new file mode 100644
index 0000000..ac50136
--- /dev/null
+++ b/src/views/board/BoardDetailView.vue
@@ -0,0 +1,321 @@
+
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+ {{ post.title }}
+
+ {{ post.authorName }}
+ · {{ formatRelative(post.createdAt) }}
+ · 조회 {{ post.viewCount }}
+
+
+
+
+
+ 🚫 열람 제한된 글입니다 (관리자만 열람)
+ — 사유: {{ post.blockReason }}
+
+
+
+
+ 🚫 관리자에 의해 열람이 제한된 게시글입니다.
+
+
+
+
+
+
+ {{ t }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/board/BoardListView.vue b/src/views/board/BoardListView.vue
new file mode 100644
index 0000000..54c28c5
--- /dev/null
+++ b/src/views/board/BoardListView.vue
@@ -0,0 +1,473 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ searchTypeLabel }} 검색: "{{ activeKeyword }}"
+
+
+
+ {{ error }}
+ 불러오는 중...
+
+
+
+
+ | 번호 |
+ 제목 |
+ 작성자 |
+ 조회 |
+ 작성일 |
+
+
+
+
+ | {{ rowNumber(index) }} |
+
+
+ 🚫 관리자에 의해 제한된 게시글입니다.
+
+
+ {{ p.title }}
+ 제한
+
+
+ |
+ {{ p.authorName }} |
+ {{ p.viewCount }} |
+ {{ formatRelative(p.createdAt) }} |
+
+
+
+ 게시글이 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/board/BoardWriteView.vue b/src/views/board/BoardWriteView.vue
new file mode 100644
index 0000000..e33d3ad
--- /dev/null
+++ b/src/views/board/BoardWriteView.vue
@@ -0,0 +1,202 @@
+
+
+
+
+ {{ isEdit ? '글 수정' : '글쓰기' }}
+
+
+
+
+
+
댓글 {{ post.comments.length }}
+ ++-
+
+ {{ c.authorName }}
+ {{ formatRelative(c.createdAt) }}
+
+
+
+
+
+
+
+첫 댓글을 남겨보세요.
+ + +