feat(wallets): 순서변경 잠금 모드 + 계좌명 클릭 이동 (오작동 방지)
Deploy / deploy (push) Successful in 33s

- 화면이동 화살표(›) 제거 — 삭제 버튼 옆이라 오탭 위험
- 계좌 클릭 대신 '계좌명' 클릭 시에만 상세 이동(링크 색상 표시)
- 순서변경은 기본 잠금 — '순서변경' 버튼으로 켤 때만 드래그 핸들·재정렬 활성
- 순서변경 모드에선 수정/삭제 버튼 숨겨 오작동 방지, 드래그 낙관적 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-07 22:08:53 +09:00
parent 5beb999535
commit b70742b79c
+56 -17
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, 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'
@@ -35,6 +35,8 @@ const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 }) const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// 순서변경(잠금) 모드 — 평소엔 잠금(드래그·핸들 숨김), 켤 때만 드래그 가능
const reorderMode = ref(false)
// 종류별 그룹(탭 제거) — TABS 순서로, 계좌가 있는 종류만 노출 // 종류별 그룹(탭 제거) — TABS 순서로, 계좌가 있는 종류만 노출
const groups = computed(() => const groups = computed(() =>
TABS.map((t) => ({ ...t, wallets: wallets.value.filter((w) => w.type === t.key) })).filter( TABS.map((t) => ({ ...t, wallets: wallets.value.filter((w) => w.type === t.key) })).filter(
@@ -79,6 +81,7 @@ function destroySortables() {
} }
function initSortables() { function initSortables() {
destroySortables() destroySortables()
if (!reorderMode.value) return // 잠금 상태에선 드래그 비활성화
document.querySelectorAll('.wallet-group-list').forEach((el) => { document.querySelectorAll('.wallet-group-list').forEach((el) => {
sortables.push( sortables.push(
Sortable.create(el, { Sortable.create(el, {
@@ -88,12 +91,21 @@ function initSortables() {
if (evt.oldIndex === evt.newIndex) return if (evt.oldIndex === evt.newIndex) return
const type = el.dataset.type const type = el.dataset.type
const ids = [...el.querySelectorAll(':scope > .wallet-item')].map((li) => Number(li.dataset.id)) const ids = [...el.querySelectorAll(':scope > .wallet-item')].map((li) => Number(li.dataset.id))
// 낙관적 반영: Sortable 이 옮긴 DOM 순서에 맞춰 데이터도 재정렬(Vue 재렌더 정합)
const byId = new Map(wallets.value.map((w) => [w.id, w]))
const moved = ids.map((id) => byId.get(id)).filter(Boolean)
wallets.value = [...moved, ...wallets.value.filter((w) => w.type !== type)]
persistOrder(type, ids) persistOrder(type, ids)
}, },
}), }),
) )
}) })
} }
// 순서변경 모드 토글 시 드래그 바인딩/해제
watch(reorderMode, async () => {
await nextTick()
initSortables()
})
async function persistOrder(type, ids) { async function persistOrder(type, ids) {
try { try {
await accountApi.reorderWallets(type, ids) await accountApi.reorderWallets(type, ids)
@@ -282,9 +294,15 @@ onBeforeUnmount(destroySortables)
</div> </div>
<div class="wallet-head"> <div class="wallet-head">
<button
v-if="wallets.length > 1"
type="button" class="text-btn reorder-btn" :class="{ active: reorderMode }"
@click="reorderMode = !reorderMode"
>{{ reorderMode ? '완료' : '순서변경' }}</button>
<IconBtn class="tab-add" icon="plus" title="계좌 추가" variant="primary" @click="openCreate" /> <IconBtn class="tab-add" icon="plus" title="계좌 추가" variant="primary" @click="openCreate" />
</div> </div>
<p v-if="reorderMode" class="msg reorder-hint"> 손잡이를 끌어 순서를 바꾸세요. 끝나면 <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>
@@ -293,11 +311,14 @@ onBeforeUnmount(destroySortables)
<h3 class="wg-title">{{ g.label }}</h3> <h3 class="wg-title">{{ g.label }}</h3>
<ul class="wallet-list wallet-group-list" :data-type="g.key"> <ul class="wallet-list wallet-group-list" :data-type="g.key">
<li v-for="w in g.wallets" :key="w.id" :data-id="w.id" class="wallet-item"> <li v-for="w in g.wallets" :key="w.id" :data-id="w.id" class="wallet-item">
<div class="wallet-row" @click="goDetail(w)"> <div class="wallet-row">
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop></span> <span v-if="reorderMode" class="drag-handle" title="드래그하여 순서 변경"></span>
<div class="info"> <div class="info">
<div class="line1"> <div class="line1">
<span class="name">{{ w.name }}</span> <span
class="name" :class="{ link: !reorderMode }"
@click="!reorderMode && goDetail(w)"
>{{ w.name }}</span>
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span> <span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div> </div>
<span class="sub"> <span class="sub">
@@ -320,11 +341,10 @@ onBeforeUnmount(destroySortables)
<div v-if="w.type === 'INVEST'" class="balance">{{ won(w.investedAmount) }}</div> <div v-if="w.type === 'INVEST'" class="balance">{{ won(w.investedAmount) }}</div>
<div v-else class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div> <div v-else class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
</div> </div>
<div class="actions" @click.stop> <div v-if="!reorderMode" class="actions">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" /> <IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div> </div>
<span class="go-chevron" aria-hidden="true"></span>
</div> </div>
</li> </li>
</ul> </ul>
@@ -470,11 +490,33 @@ button.primary {
} }
.wallet-head { .wallet-head {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.tab-add { .tab-add {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
}
.text-btn {
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.75;
font-size: 0.88rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.reorder-btn.active {
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
opacity: 1;
}
.reorder-hint {
font-size: 0.82rem;
opacity: 0.7;
margin: 0 0 0.6rem;
} }
.wallet-group { .wallet-group {
margin-bottom: 1.4rem; margin-bottom: 1.4rem;
@@ -487,12 +529,6 @@ button.primary {
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.go-chevron {
flex: 0 0 auto;
opacity: 0.35;
font-size: 1.1rem;
padding-left: 0.15rem;
}
.wallet-list { .wallet-list {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;
@@ -506,10 +542,6 @@ button.primary {
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
padding: 0.7rem 0; padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
} }
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
@@ -663,6 +695,13 @@ button.primary {
.name { .name {
font-weight: 600; font-weight: 600;
} }
.name.link {
cursor: pointer;
color: hsla(160, 100%, 37%, 1);
}
.name.link:active {
opacity: 0.7;
}
.badge { .badge {
font-size: 0.72rem; font-size: 0.72rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);