feat: 가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-05-31 15:42:52 +09:00
parent 2485ea05bf
commit 13308a82ef
39 changed files with 7385 additions and 111 deletions
+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>