refactor(wallets): 계좌 추가/수정을 모달 → 별도 화면(화면전환)
Deploy / deploy (push) Failing after 12m40s

- WalletFormView.vue 신규: /account/wallets/new · /:id/edit 라우트
- 생성 시 '계좌 종류' 선택 UI 복원(탭 제거 후 빠졌던 부분)
- AccountWalletView: 모달·폼 로직 제거, +/수정 버튼은 화면 이동
- 상세 라우트는 :id(\d+)로 제한(new와 충돌 방지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-09 22:33:50 +09:00
parent 13a0f0d40f
commit ca09dce2bd
3 changed files with 345 additions and 212 deletions
+8 -211
View File
@@ -1,12 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue'
import AppModal from '@/components/ui/AppModal.vue'
const dialog = useDialog()
const auth = useAuthStore()
@@ -20,17 +19,6 @@ const TABS = [
{ key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' },
]
const CARD_TYPES = [
{ value: 'CREDIT', label: '신용카드' },
{ value: 'CHECK', label: '체크카드' },
]
const LOAN_METHODS = [
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
{ value: 'BULLET', label: '만기일시' },
{ value: '', label: '선택안함' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false)
@@ -44,35 +32,17 @@ const groups = computed(() =>
),
)
const formOpen = ref(false)
const editId = ref(null)
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
// 대출(LOAN) 전용
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
loanPayment: null,
})
const submitting = ref(false)
const formError = ref(null)
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
// 계좌 클릭 → 별도 상세 화면(계좌별 내역)
function goDetail(w) {
router.push(`/account/wallets/${w.id}`)
}
// 추가/수정은 별도 화면으로(모달 → 화면전환)
function openCreate() {
router.push('/account/wallets/new')
}
function openEdit(w) {
router.push(`/account/wallets/${w.id}/edit`)
}
// ===== 드래그앤드랍 순서 변경 (종류별 그룹마다 SortableJS, 터치 지원) =====
let sortables = []
@@ -139,22 +109,6 @@ function toggleReveal(id) {
s.has(id) ? s.delete(id) : s.add(id)
revealedAccts.value = s
}
function issuerLabel(t) {
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
if (t === 'CARD') return '초기 미결제 금액'
if (t === 'INVEST') return '투자금(투입원금)'
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
return '기록 시작 시 잔액'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() {
loading.value = true
error.value = null
@@ -172,91 +126,6 @@ async function load() {
initSortables()
}
function openCreate() {
editId.value = null
Object.assign(form, {
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
loanPayment: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
loanPayment: w.loanPayment ?? null,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// 부채는 음수로 저장 (갚을 금액)
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
loanPayment: isLoan && form.loanPayment ? Number(form.loanPayment) : null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(w) {
if (!(await dialog.confirm(`'${w.name}' 을(를) 삭제할까요?`, { title: '삭제', danger: true }))) return
try {
@@ -362,78 +231,6 @@ onBeforeUnmount(destroySortables)
<button type="button" class="empty-cta" @click="openCreate"> 계좌 추가</button>
</div>
<!-- 추가/수정 모달 -->
<AppModal v-model="formOpen" :title="`${TABS.find((t) => t.key === form.type)?.label} ${editId ? '수정' : '추가'}`">
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="form.type === 'CARD'" class="field">
<span class="field-label">카드 종류</span>
<div class="chip-group">
<button
v-for="c in CARD_TYPES" :key="c.value"
type="button" class="chip" :class="{ active: form.cardType === c.value }"
:disabled="submitting" @click="form.cardType = c.value"
>{{ c.label }}</button>
</div>
</div>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
</label>
<p class="form-hint">
<b>투자금</b>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.001"
placeholder="예: 5.253" :disabled="submitting" />
</label>
<div class="field">
<span class="field-label">상환방식</span>
<div class="chip-group">
<button
v-for="m in LOAN_METHODS" :key="m.value"
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
:disabled="submitting" @click="form.loanMethod = m.value"
>{{ m.label }}</button>
</div>
</div>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
<label v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''"> 상환금(선택)
<input v-model.number="form.loanPayment" type="number" min="0"
placeholder="은행 조회 고정 월납입금 (예: 697868)" :disabled="submitting" />
</label>
<p v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''" class="form-hint">
원리금균등은 <b> 상환금</b> 입력하면 상환표의 원금·이자가 은행 조회액과 정확히 일치합니다. (미입력 잔액·개월수로 추정)
</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</AppModal>
</section>
</template>
+324
View File
@@ -0,0 +1,324 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const editId = route.params.id ? Number(route.params.id) : null
const isEdit = editId != null
const TYPES = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CASH', label: '현금' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'MINUS', label: '마이너스통장' },
{ key: 'INVEST', label: '투자' },
]
const CARD_TYPES = [
{ value: 'CREDIT', label: '신용카드' },
{ value: 'CHECK', label: '체크카드' },
]
const LOAN_METHODS = [
{ value: 'EQUAL_PAYMENT', label: '원리금균등' },
{ value: 'EQUAL_PRINCIPAL', label: '원금균등' },
{ value: 'BULLET', label: '만기일시' },
{ value: '', label: '선택안함' },
]
const isLiability = (t) => t === 'CARD' || t === 'LOAN' || t === 'MINUS'
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
loanAmount: null,
loanRate: null,
loanMethod: '',
loanMonths: null,
loanStart: '',
loanPayment: null,
})
const loading = ref(false)
const submitting = ref(false)
const formError = ref(null)
const typeLabel = (t) => TYPES.find((x) => x.key === t)?.label || ''
function issuerLabel(t) {
return t === 'BANK' || t === 'MINUS' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
if (t === 'BANK' || t === 'CASH') return '초기 잔액'
if (t === 'CARD') return '초기 미결제 금액'
if (t === 'INVEST') return '투자금(투입원금)'
if (t === 'MINUS') return '기록 시작 시 사용 잔액'
return '기록 시작 시 잔액'
}
async function load() {
if (!isEdit) return
loading.value = true
try {
const ws = await accountApi.wallets()
const w = ws.find((x) => x.id === editId)
if (!w) {
formError.value = '계좌를 찾을 수 없습니다.'
return
}
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
loanAmount: w.loanAmount ?? null,
loanRate: w.loanRate ?? null,
loanMethod: w.loanMethod || '',
loanMonths: w.loanMonths ?? null,
loanStart: w.loanStart || '',
loanPayment: w.loanPayment ?? null,
})
} catch (e) {
formError.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
onMounted(load)
function goBack() {
if (window.history.length > 1) router.back()
else router.replace('/account/wallets')
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const isLoan = form.type === 'LOAN'
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' || form.type === 'MINUS' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// 부채는 음수로 저장 (갚을 금액)
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
loanAmount: isLoan && form.loanAmount ? Number(form.loanAmount) : null,
loanRate: isLoan && form.loanRate !== null && form.loanRate !== '' ? Number(form.loanRate) : null,
loanMethod: isLoan && form.loanMethod ? form.loanMethod : null,
loanMonths: isLoan && form.loanMonths ? Number(form.loanMonths) : null,
loanStart: isLoan && form.loanStart ? form.loanStart : null,
loanPayment: isLoan && form.loanPayment ? Number(form.loanPayment) : null,
}
try {
if (isEdit) await accountApi.updateWallet(editId, payload)
else await accountApi.createWallet(payload)
router.replace('/account/wallets')
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="wform">
<header class="wf-head">
<IconBtn icon="back" title="뒤로" @click="goBack" />
<span class="wf-title">{{ isEdit ? `${typeLabel(form.type)} 수정` : '계좌 추가' }}</span>
</header>
<p v-if="loading" class="msg">불러오는 중...</p>
<form v-else class="wallet-form" @submit.prevent="submit">
<!-- 종류 선택(생성 시에만) -->
<div v-if="!isEdit" class="field">
<span class="field-label">계좌 종류</span>
<div class="chip-group">
<button
v-for="t in TYPES" :key="t.key"
type="button" class="chip" :class="{ active: form.type === t.key }"
:disabled="submitting" @click="form.type = t.key"
>{{ t.label }}</button>
</div>
</div>
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label v-if="form.type !== 'CASH'">{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK' || form.type === 'MINUS'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="form.type === 'CARD'" class="field">
<span class="field-label">카드 종류</span>
<div class="chip-group">
<button
v-for="c in CARD_TYPES" :key="c.value"
type="button" class="chip" :class="{ active: form.cardType === c.value }"
:disabled="submitting" @click="form.cardType = c.value"
>{{ c.label }}</button>
</div>
</div>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<template v-if="form.type === 'INVEST'">
<label>현재 평가액
<input v-model.number="form.currentValue" type="number" min="0" placeholder="예: 현재 계좌 평가금액" :disabled="submitting" />
</label>
<p class="form-hint">
<b>투자금</b>(투입원금) <b>현재 평가액</b> 입력하면 손익이 자동 계산됩니다. (퇴직연금·연금·증권계좌 공통)<br />
평가액은 <b>수정</b>에서 주기적으로 갱신하세요.
</p>
</template>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<!-- 대출 전용 -->
<template v-if="form.type === 'LOAN'">
<label>대출 실행 금액(원금)
<input v-model.number="form.loanAmount" type="number" min="0"
placeholder="처음 빌린 금액 (예: 10000000)" :disabled="submitting" />
</label>
<label>연이자율(%)
<input v-model.number="form.loanRate" type="number" min="0" max="100" step="0.001"
placeholder="예: 5.253" :disabled="submitting" />
</label>
<div class="field">
<span class="field-label">상환방식</span>
<div class="chip-group">
<button
v-for="m in LOAN_METHODS" :key="m.value"
type="button" class="chip" :class="{ active: form.loanMethod === m.value }"
:disabled="submitting" @click="form.loanMethod = m.value"
>{{ m.label }}</button>
</div>
</div>
<label>대출기간(개월)
<input v-model.number="form.loanMonths" type="number" min="1"
placeholder="예: 120 (10년)" :disabled="submitting" />
</label>
<label>대출시작일
<input v-model="form.loanStart" type="date" :disabled="submitting" />
</label>
<label v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''"> 상환금(선택)
<input v-model.number="form.loanPayment" type="number" min="0"
placeholder="은행 조회 고정 월납입금 (예: 697868)" :disabled="submitting" />
</label>
<p v-if="form.loanMethod === 'EQUAL_PAYMENT' || form.loanMethod === ''" class="form-hint">
원리금균등은 <b> 상환금</b> 입력하면 상환표의 원금·이자가 은행 조회액과 정확히 일치합니다. (미입력 잔액·개월수로 추정)
</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="goBack" />
<IconBtn icon="save" :title="isEdit ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</section>
</template>
<style scoped>
.wform {
max-width: 460px;
margin: 0 auto;
}
.wf-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.wf-title {
font-size: 1.2rem;
font-weight: 700;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
.wallet-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.wallet-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.wallet-form input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
opacity: 0.7;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
padding: 0.28rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 100px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
line-height: 1.4;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chip.active {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.form-hint {
font-size: 0.76rem;
opacity: 0.65;
margin: -0.2rem 0 0.1rem;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.8rem;
}
</style>