@@ -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 : 560 px ;
margin : 0 auto ;
}
. head {
display : flex ;
align - items : center ;
justify - content : space - between ;
}
. head - actions {
display : flex ;
gap : 0.5 rem ;
}
h1 {
font - size : 1.5 rem ;
}
. hint {
font - size : 0.85 rem ;
opacity : 0.7 ;
margin : 0.5 rem 0 1 rem ;
}
input {
padding : 0.5 rem 0.7 rem ;
border : 1 px solid var ( -- color - border ) ;
border - radius : 4 px ;
background : var ( -- color - background - soft ) ;
color : var ( -- color - text ) ;
}
. tabs {
display : flex ;
gap : 0.25 rem ;
border - bottom : 1 px solid var ( -- color - border ) ;
margin - bottom : 1 rem ;
}
. tabs button {
border : 0 ;
border - bottom : 2 px solid transparent ;
border - radius : 0 ;
background : transparent ;
color : var ( -- color - text ) ;
padding : 0.6 rem 1 rem ;
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.5 rem ;
margin - bottom : 1 rem ;
}
. nc - line {
display : flex ;
gap : 0.5 rem ;
}
. nc - line input {
flex : 1 ;
min - width : 0 ;
}
. major - list {
list - style : none ;
padding - left : 0 ;
margin : 0 ;
}
. major - block {
border : 1 px solid var ( -- color - border ) ;
border - radius : 8 px ;
margin - bottom : 0.5 rem ;
overflow : hidden ;
}
. major - row {
background : var ( -- color - background - soft ) ;
border - bottom : 0 ! important ;
}
. major - badge {
flex : 0 0 auto ;
font - size : 0.62 rem ;
font - weight : 700 ;
color : hsla ( 160 , 100 % , 37 % , 1 ) ;
border : 1 px solid hsla ( 160 , 100 % , 37 % , 0.6 ) ;
border - radius : 4 px ;
padding : 0.05 rem 0.25 rem ;
}
. sub - list {
list - style : none ;
padding : 0 0 0 0.4 rem ;
margin : 0 ;
}
. sub - item : last - child {
border - bottom : 0 ;
}
. cat - row {
display : flex ;
flex - direction : column ;
gap : 0.35 rem ;
padding : 0.5 rem 0.5 rem ;
border - bottom : 1 px solid var ( -- color - border ) ;
background : var ( -- color - background ) ;
}
. cat - main {
display : flex ;
gap : 0.4 rem ;
align - items : center ;
}
. drag - handle {
cursor : grab ;
user - select : none ;
opacity : 0.5 ;
font - size : 1.1 rem ;
padding : 0 0.2 rem ;
touch - action : none ;
}
. drag - handle : active {
cursor : grabbing ;
}
. cat - name {
flex : 1 ;
min - width : 0 ;
}
. cat - row . child {
padding - left : 1.2 rem ;
}
. cat - row . child . cat - name {
background : var ( -- color - background - soft ) ;
}
. sub - mark {
opacity : 0.45 ;
}
. hint . sm {
font - size : 0.78 rem ;
margin : 0 0 0.75 rem ;
}
. msg {
margin : 0.75 rem 0 ;
}
. msg . error {
color : # c0392b ;
}
< / style >