Merge branch 'dev'
Deploy / deploy (push) Failing after 10m39s
CI / build (push) Failing after 12m8s

This commit is contained in:
ByungCheol
2026-06-24 23:03:52 +09:00
5 changed files with 383 additions and 3 deletions
+17
View File
@@ -30,6 +30,23 @@ export const adminApi = {
return http.put('/admin/board-setting', { tagCategoryId })
},
// 기본(디폴트) 분류 관리 (관리자) — 사용자가 '기본 분류 불러오기'로 가져감
defaultCategories() {
return http.get('/admin/default-categories')
},
createDefaultCategory(payload) {
return http.post('/admin/default-categories', payload)
},
updateDefaultCategory(id, payload) {
return http.put(`/admin/default-categories/${id}`, payload)
},
removeDefaultCategory(id) {
return http.delete(`/admin/default-categories/${id}`)
},
reorderDefaultCategories(type, ids) {
return http.put('/admin/default-categories/reorder', { type, ids })
},
// 앱 설정 (관리자)
getSignupSetting() {
return http.get('/admin/signup-setting')
+1
View File
@@ -40,6 +40,7 @@ const ui = useUiStore()
<hr class="menu-divider" />
<RouterLink to="/users" class="menu-item">회원 관리</RouterLink>
<RouterLink to="/admin/tags" class="menu-item">태그 관리(관리자)</RouterLink>
<RouterLink to="/admin/default-categories" class="menu-item">기본 분류 설정</RouterLink>
</template>
</nav>
</aside>
+6
View File
@@ -54,6 +54,12 @@ const router = createRouter({
component: () => import('../views/admin/TagAdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/default-categories',
name: 'admin-default-categories',
component: () => import('../views/admin/DefaultCategoryView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
path: '/account',
+3 -3
View File
@@ -157,8 +157,8 @@ function removeCategory(c) {
})
}
function importExisting() {
askConfirm('기 분류 불러오기', '기존 내역에서 사용한 분류들을 목록으로 가져올까요?', async () => {
function importDefaults() {
askConfirm('기 분류 불러오기', '관리자가 설정한 기본 분류를 내 분류로 가져올까요?\n같은 이름은 건너뜁니다.', async () => {
error.value = null
try {
categories.value = await accountApi.importCategories()
@@ -187,7 +187,7 @@ onBeforeUnmount(destroySortables)
<header class="head">
<h1>분류 관리</h1>
<div class="head-actions">
<button type="button" class="text-btn" @click="importExisting"> 분류 불러오기</button>
<button type="button" class="text-btn" @click="importDefaults"> 분류 불러오기</button>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</div>
</header>
+356
View File
@@ -0,0 +1,356 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { adminApi } from '@/api/adminApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const dialog = useDialog()
const categories = ref([])
const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상
const loading = ref(false)
const error = ref(null)
const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('')
const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가)
function showError(e, fallback) {
error.value = e.response?.data?.message || fallback
}
function syncRows() {
rows.value = categories.value.filter((c) => c.type === activeType.value)
}
watch([categories, activeType], syncRows, { immediate: true })
const majors = computed(() => rows.value.filter((c) => c.parentId == null))
const tree = computed(() =>
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
)
async function load() {
loading.value = true
error.value = null
try {
categories.value = await adminApi.defaultCategories()
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function reload() {
await load()
await nextTick()
initSortable()
}
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
const majorListEl = ref(null)
let sortables = []
function destroySortables() {
sortables.forEach((s) => s.destroy())
sortables = []
}
function initSortable() {
destroySortables()
if (!majorListEl.value) return
sortables.push(
Sortable.create(majorListEl.value, {
handle: '.major-handle',
draggable: '.major-block',
animation: 150,
onEnd: () => {
const ids = [...majorListEl.value.querySelectorAll(':scope > .major-block')].map((li) => Number(li.dataset.id))
persistOrder(ids)
},
}),
)
majorListEl.value.querySelectorAll('.sub-list').forEach((ul) => {
sortables.push(
Sortable.create(ul, {
handle: '.sub-handle',
draggable: '.sub-item',
group: 'sub-' + ul.dataset.parent,
animation: 150,
onEnd: () => {
const ids = [...ul.querySelectorAll(':scope > .sub-item')].map((li) => Number(li.dataset.id))
if (ids.length) persistOrder(ids)
},
}),
)
})
}
function reorderLocal(ids) {
const idSet = new Set(ids)
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
let k = 0
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
syncRows()
}
async function persistOrder(ids) {
reorderLocal(ids)
try {
await adminApi.reorderDefaultCategories(activeType.value, ids)
} catch (e) {
showError(e, '순서 저장에 실패했습니다.')
}
await reload()
}
async function addCategory() {
const name = newName.value.trim()
if (!name) return
error.value = null
try {
await adminApi.createDefaultCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = ''
await reload()
} catch (e) {
showError(e, '추가에 실패했습니다.')
}
}
async function saveCategory(c) {
const name = (c.name || '').trim()
if (!name) return
error.value = null
try {
await adminApi.updateDefaultCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
await reload()
} catch (e) {
showError(e, '수정에 실패했습니다.')
}
}
async function removeCategory(c) {
if (!(await dialog.confirm(`'${c.name}' 기본 분류를 삭제할까요?`, { title: '기본 분류 삭제', danger: true }))) return
error.value = null
try {
await adminApi.removeDefaultCategory(c.id)
await reload()
} catch (e) {
showError(e, '삭제에 실패했습니다.')
}
}
watch(activeType, async () => {
await nextTick()
initSortable()
})
onMounted(async () => {
await reload()
})
onBeforeUnmount(destroySortables)
</script>
<template>
<section class="cat">
<header class="head">
<h1>기본 분류 관리</h1>
<div class="head-actions">
<IconBtn icon="back" title="뒤로" @click="router.back()" />
</div>
</header>
<p class="hint">
전체 사용자 공용 <b>기본 분류</b> 템플릿입니다. 사용자가 분류 관리에서 <b>기본 분류 불러오기</b> 자기 분류에 복사해 씁니다.
<b>대분류 아래 소분류</b> 묶을 있습니다(2단계).
</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">
<select v-model="newParent" class="parent-sel" title="대분류 선택">
<option value="">대분류로 추가</option>
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
<div class="nc-line">
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
</div>
</form>
<p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && tree.length" ref="majorListEl" class="major-list">
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
<div class="cat-row major-row">
<div class="cat-main">
<span class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<span class="major-badge"></span>
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
</div>
</div>
<ul class="sub-list" :data-parent="g.major.id">
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
<div class="cat-main">
<span class="drag-handle sub-handle" title="소분류 순서 변경"></span>
<span class="sub-mark"></span>
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
</div>
</li>
</ul>
</li>
</ul>
<p v-if="!loading && !tree.length" 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);
}
.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;
color: var(--color-text);
padding: 0.6rem 1rem;
cursor: pointer;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.new-cat {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.nc-line {
display: flex;
gap: 0.5rem;
}
.nc-line input {
flex: 1;
min-width: 0;
}
.major-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.major-block {
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.major-row {
background: var(--color-background-soft);
border-bottom: 0 !important;
}
.major-badge {
flex: 0 0 auto;
font-size: 0.62rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
padding: 0.05rem 0.25rem;
}
.sub-list {
list-style: none;
padding: 0 0 0 0.4rem;
margin: 0;
}
.sub-item:last-child {
border-bottom: 0;
}
.cat-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.cat-main {
display: flex;
gap: 0.4rem;
align-items: center;
}
.drag-handle {
cursor: grab;
user-select: none;
opacity: 0.5;
font-size: 1.1rem;
padding: 0 0.2rem;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.cat-name {
flex: 1;
min-width: 0;
}
.cat-row.child {
padding-left: 1.2rem;
}
.cat-row.child .cat-name {
background: var(--color-background-soft);
}
.sub-mark {
opacity: 0.45;
}
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
</style>