Merge branch 'dev'
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user