fix: 분류 관리 — 삭제 확인 인앱 모달 / 행별 대분류 select 제거 / 추가옵션 대분류명만
CI / build (push) Failing after 11m34s

- 삭제·불러오기 네이티브 confirm('sb_pt' 제목) → 인앱 확인 모달로 교체, 오류도 인라인 표시
- 각 행 아래 '대분류' 선택 박스 제거(불필요) — 계층은 추가 폼·드래그로 관리
- 추가 폼 대분류 옵션 'OOO의 소분류' → 'OOO'(대분류명만)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-24 21:31:39 +09:00
parent 8add41d94f
commit 643bb8011b
+107 -42
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
@@ -15,18 +15,32 @@ const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('') const newName = ref('')
const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가) const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가)
// 인앱 확인 모달 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
const confirmState = reactive({ open: false, title: '', message: '', onOk: null })
function askConfirm(title, message, onOk) {
confirmState.title = title
confirmState.message = message
confirmState.onOk = onOk
confirmState.open = true
}
function confirmOk() {
const fn = confirmState.onOk
confirmState.open = false
if (fn) fn()
}
// 액션 오류도 네이티브 alert 대신 화면 상단 메시지로
function showError(e, fallback) {
error.value = e.response?.data?.message || fallback
}
// categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬) // categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬)
function syncRows() { function syncRows() {
rows.value = categories.value.filter((c) => c.type === activeType.value) rows.value = categories.value.filter((c) => c.type === activeType.value)
} }
watch([categories, activeType], syncRows, { immediate: true }) watch([categories, activeType], syncRows, { immediate: true })
// 현재 탭의 대분류(parent 없는 것) 목록 — 부모 선택 옵션 // 현재 탭의 대분류(parent 없는 것) 목록 — 추가 폼 대분류 선택
const majors = computed(() => rows.value.filter((c) => c.parentId == null)) const majors = computed(() => rows.value.filter((c) => c.parentId == null))
// 부모(대분류) 선택 옵션: 자기 자신 제외
function parentOptions(c) {
return majors.value.filter((m) => m.id !== c.id)
}
// 대분류 → 소분류 트리 (major/children 모두 실제 row 참조 유지 → v-model 바인딩 가능) // 대분류 → 소분류 트리 (major/children 모두 실제 row 참조 유지 → v-model 바인딩 가능)
const tree = computed(() => const tree = computed(() =>
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })), majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
@@ -100,7 +114,7 @@ async function persistOrder(ids) {
try { try {
await accountApi.reorderCategories(activeType.value, ids) await accountApi.reorderCategories(activeType.value, ids)
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.') showError(e, '순서 저장에 실패했습니다.')
} }
await reload() await reload()
} }
@@ -108,46 +122,52 @@ async function persistOrder(ids) {
async function addCategory() { async function addCategory() {
const name = newName.value.trim() const name = newName.value.trim()
if (!name) return if (!name) return
error.value = null
try { try {
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null }) await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = '' newName.value = ''
await reload() await reload()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '추가에 실패했습니다.') showError(e, '추가에 실패했습니다.')
} }
} }
async function saveCategory(c) { async function saveCategory(c) {
const name = (c.name || '').trim() const name = (c.name || '').trim()
if (!name) return if (!name) return
error.value = null
try { try {
// parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림) // parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null }) await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
await reload() await reload()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '수정에 실패했습니다.') showError(e, '수정에 실패했습니다.')
} }
} }
async function removeCategory(c) { function removeCategory(c) {
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return askConfirm('분류 삭제', `'${c.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`, async () => {
try { error.value = null
await accountApi.removeCategory(c.id) try {
await reload() await accountApi.removeCategory(c.id)
} catch (e) { await reload()
alert(e.response?.data?.message || '삭제에 실패했습니다.') } catch (e) {
} showError(e, '삭제에 실패했습니다.')
}
})
} }
async function importExisting() { function importExisting() {
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return askConfirm('기존 분류 불러오기', '기존 내역에서 사용한 분류들을 목록으로 가져올까요?', async () => {
try { error.value = null
categories.value = await accountApi.importCategories() try {
await nextTick() categories.value = await accountApi.importCategories()
initSortable() await nextTick()
} catch (e) { initSortable()
alert(e.response?.data?.message || '불러오기에 실패했습니다.') } catch (e) {
} showError(e, '불러오기에 실패했습니다.')
}
})
} }
// 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화 // 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화
@@ -181,14 +201,14 @@ onBeforeUnmount(destroySortables)
<form class="new-cat" @submit.prevent="addCategory"> <form class="new-cat" @submit.prevent="addCategory">
<select v-model="newParent" class="parent-sel" title="대분류 선택"> <select v-model="newParent" class="parent-sel" title="대분류 선택">
<option value="">대분류로 추가</option> <option value="">대분류로 추가</option>
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }} 소분류</option> <option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }}</option>
</select> </select>
<div class="nc-line"> <div class="nc-line">
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" /> <input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" /> <IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
</div> </div>
</form> </form>
<p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다. 다른 대분류로 옮기려면 행의 대분류 바꾸세요.</p> <p class="hint sm"> 손잡이로 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다.</p>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
@@ -203,13 +223,6 @@ onBeforeUnmount(destroySortables)
<IconBtn icon="check" title="저장" size="sm" @click="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)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
</div> </div>
<label class="cat-parent">
<span class="pl">대분류</span>
<select v-model="g.major.parentId" class="parent-sel" @change="saveCategory(g.major)">
<option :value="null">(없음 · 대분류)</option>
<option v-for="m in parentOptions(g.major)" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</label>
</div> </div>
<ul class="sub-list" :data-parent="g.major.id"> <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"> <li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
@@ -220,13 +233,6 @@ onBeforeUnmount(destroySortables)
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" /> <IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
</div> </div>
<label class="cat-parent">
<span class="pl">대분류</span>
<select v-model="s.parentId" class="parent-sel" @change="saveCategory(s)">
<option v-for="m in parentOptions(s)" :key="m.id" :value="m.id">{{ m.name }}</option>
<option :value="null">(대분류로 분리)</option>
</select>
</label>
</li> </li>
</ul> </ul>
</li> </li>
@@ -234,6 +240,22 @@ onBeforeUnmount(destroySortables)
<p v-if="!loading && !tree.length" class="msg"> <p v-if="!loading && !tree.length" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다. 등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p> </p>
<!-- 인앱 확인 모달 (네이티브 confirm 대체 제목 'sb_pt' 노출 방지) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="confirmState.open" class="modal-backdrop" @click.self="confirmState.open = false">
<div class="modal confirm-modal" role="dialog" aria-modal="true">
<h2>{{ confirmState.title }}</h2>
<p class="confirm-body">{{ confirmState.message }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="confirmState.open = false" />
<IconBtn icon="check" title="확인" variant="primary" @click="confirmOk" />
</div>
</div>
</div>
</Transition>
</Teleport>
</section> </section>
</template> </template>
@@ -405,4 +427,47 @@ button.danger {
.msg.error { .msg.error {
color: #c0392b; color: #c0392b;
} }
/* 인앱 확인 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.5rem 1.25rem calc(1.25rem + env(safe-area-inset-bottom));
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 12px;
}
.modal h2 {
font-size: 1.05rem;
margin: 0 0 0.6rem;
}
.confirm-body {
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>