Files
sb-front/src/views/admin/DefaultCategoryView.vue
T
ByungCheol c4872c93a8
CI / build (push) Failing after 11m0s
feat: 페이지 타이틀 제거(헤더만 표기) + 게시글 상세 목록아이콘/공지 체크박스화
- 각 화면 내 <h1> 페이지 타이틀 제거 → 헤더 타이틀로 일원화
  (가계부 pending 카운트는 유지, 액션 헤더는 우측 정렬)
- 게시글 상세: 공지 등록/해제 버튼 제거 + '목록' 아이콘 추가(list 아이콘 신규)
- 글 작성/수정 공지 체크박스로 일원화(편집 시 기존값 로드)
- ProfileEdit/AccountInfo 화면 내 뒤로가기 제거, 미사용 router 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:20 +09:00

349 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import Sortable from 'sortablejs'
import { adminApi } from '@/api/adminApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
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">
<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>