c4872c93a8
CI / build (push) Failing after 11m0s
- 각 화면 내 <h1> 페이지 타이틀 제거 → 헤더 타이틀로 일원화 (가계부 pending 카운트는 유지, 액션 헤더는 우측 정렬) - 게시글 상세: 공지 등록/해제 버튼 제거 + '목록' 아이콘 추가(list 아이콘 신규) - 글 작성/수정 공지 체크박스로 일원화(편집 시 기존값 로드) - ProfileEdit/AccountInfo 화면 내 뒤로가기 제거, 미사용 router 정리 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
9.5 KiB
Vue
349 lines
9.5 KiB
Vue
<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>
|