Merge branch 'dev'
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<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 Sortable from 'sortablejs'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
@@ -15,18 +15,32 @@ const activeType = ref('EXPENSE') // EXPENSE / INCOME
|
||||
const newName = 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 순 정렬)
|
||||
function syncRows() {
|
||||
rows.value = categories.value.filter((c) => c.type === activeType.value)
|
||||
}
|
||||
watch([categories, activeType], syncRows, { immediate: true })
|
||||
|
||||
// 현재 탭의 대분류(parent 없는 것) 목록 — 부모 선택 옵션용
|
||||
// 현재 탭의 대분류(parent 없는 것) 목록 — 추가 폼 대분류 선택용
|
||||
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 바인딩 가능)
|
||||
const tree = computed(() =>
|
||||
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
|
||||
@@ -100,7 +114,7 @@ async function persistOrder(ids) {
|
||||
try {
|
||||
await accountApi.reorderCategories(activeType.value, ids)
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
|
||||
showError(e, '순서 저장에 실패했습니다.')
|
||||
}
|
||||
await reload()
|
||||
}
|
||||
@@ -108,46 +122,52 @@ async function persistOrder(ids) {
|
||||
async function addCategory() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
error.value = null
|
||||
try {
|
||||
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
|
||||
newName.value = ''
|
||||
await reload()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '추가에 실패했습니다.')
|
||||
showError(e, '추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(c) {
|
||||
const name = (c.name || '').trim()
|
||||
if (!name) return
|
||||
error.value = null
|
||||
try {
|
||||
// parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
|
||||
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
|
||||
await reload()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '수정에 실패했습니다.')
|
||||
showError(e, '수정에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCategory(c) {
|
||||
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return
|
||||
try {
|
||||
await accountApi.removeCategory(c.id)
|
||||
await reload()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
function removeCategory(c) {
|
||||
askConfirm('분류 삭제', `'${c.name}' 분류를 삭제할까요?\n기존 내역의 분류명은 그대로 유지됩니다.`, async () => {
|
||||
error.value = null
|
||||
try {
|
||||
await accountApi.removeCategory(c.id)
|
||||
await reload()
|
||||
} catch (e) {
|
||||
showError(e, '삭제에 실패했습니다.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function importExisting() {
|
||||
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return
|
||||
try {
|
||||
categories.value = await accountApi.importCategories()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '불러오기에 실패했습니다.')
|
||||
}
|
||||
function importExisting() {
|
||||
askConfirm('기존 분류 불러오기', '기존 내역에서 사용한 분류들을 목록으로 가져올까요?', async () => {
|
||||
error.value = null
|
||||
try {
|
||||
categories.value = await accountApi.importCategories()
|
||||
await nextTick()
|
||||
initSortable()
|
||||
} catch (e) {
|
||||
showError(e, '불러오기에 실패했습니다.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화
|
||||
@@ -181,14 +201,14 @@ onBeforeUnmount(destroySortables)
|
||||
<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>
|
||||
<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 class="hint sm">≡ 손잡이로 순서 변경 — <b>대분류</b>는 소분류와 함께, <b>소분류</b>는 그 대분류 안에서만 이동합니다.</p>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</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="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
|
||||
</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>
|
||||
<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">
|
||||
@@ -220,13 +233,6 @@ onBeforeUnmount(destroySortables)
|
||||
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
|
||||
</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>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -234,6 +240,22 @@ onBeforeUnmount(destroySortables)
|
||||
<p v-if="!loading && !tree.length" class="msg">
|
||||
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -405,4 +427,47 @@ button.danger {
|
||||
.msg.error {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user